1164 lines
61 KiB
JavaScript
1164 lines
61 KiB
JavaScript
import { app } from "../../../scripts/app.js";
|
|
import { api } from "../../../scripts/api.js";
|
|
|
|
console.log("Image Annotator Ultimate Pro loading...");
|
|
|
|
// ============ Utility Functions ============
|
|
function chainCallback(object, property, callback) {
|
|
if (object == undefined) return;
|
|
if (property in object) {
|
|
const callback_orig = object[property]
|
|
object[property] = function () {
|
|
const r = callback_orig.apply(this, arguments);
|
|
callback.apply(this, arguments);
|
|
return r
|
|
};
|
|
} else { object[property] = callback; }
|
|
}
|
|
|
|
function hexToRgba(hex, alpha) {
|
|
if (!hex || typeof hex !== 'string') return `rgba(34, 197, 94, ${alpha})`;
|
|
let h = hex.replace('#', '');
|
|
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
|
const r = parseInt(h.slice(0, 2), 16) || 0, g = parseInt(h.slice(2, 4), 16) || 0, b = parseInt(h.slice(4, 6), 16) || 0;
|
|
return `rgba(${r},${g},${b},${alpha})`;
|
|
}
|
|
|
|
const TOOLS = { MOVE: "move", ARROW: "arrow", PEN: "pen", RECT: "rect", LASSO: "lasso" };
|
|
|
|
const TOOL_NAMES = {
|
|
arrow: "Select",
|
|
move: "Hand",
|
|
pen: "Point",
|
|
rect: "Rectangle",
|
|
lasso: "Lasso"
|
|
};
|
|
|
|
const COLORS = {
|
|
bg: "#0d0f12", dark: "#13161a", panel: "#1a1d22", section: "#22262d",
|
|
hover: "#2a2f38", border: "#2e333c", text: "#e4e8ed", dim: "#8b929c",
|
|
accent: "#22c55e", danger: "#ef4444", info: "#3b82f6",
|
|
semantic: { red: "#ef4444", green: "#22c55e", blue: "#3b82f6" }
|
|
};
|
|
|
|
const CSS = `
|
|
.ult-editor { display: flex; background: #15171b; border: 1px solid #2b313a; border-radius: 6px; overflow: hidden; width: 100%; height: 100%; color: #d5d9df; font-family: 'JetBrains Mono', 'Consolas', monospace; position: relative; user-select: none; }
|
|
.ult-editor.ult-resizing-v, .ult-editor.ult-resizing-v * { user-select: none !important; cursor: row-resize !important; }
|
|
.ult-editor.ult-resizing-h, .ult-editor.ult-resizing-h * { user-select: none !important; cursor: col-resize !important; }
|
|
.ult-sidebar { width: 300px; min-width: 260px; max-width: 500px; background: #191c21; border-right: 1px solid #2e343d; display: flex; flex-direction: column; flex-shrink: 0; position: relative; overflow: hidden; }
|
|
.ult-resizer { position: absolute; right: -4px; top: 0; width: 10px; height: 100%; cursor: col-resize; z-index: 25; touch-action: none; }
|
|
.ult-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 3px; width: 4px; border-radius: 2px; background: transparent; transition: background 0.15s; }
|
|
.ult-resizer:hover::after { background: ${COLORS.accent}; }
|
|
.ult-header { padding: 14px 12px; border-bottom: 1px solid #2a3038; border-top: 4px solid #1f9cff; background: #14171c; }
|
|
.ult-header h1 { font-size: 14px; color: #d9dde3; margin: 0; font-weight: 600; }
|
|
.ult-header-info { margin-top: 4px; font-size: 10px; color: ${COLORS.dim}; display: flex; gap: 12px; }
|
|
|
|
.ult-section { border-bottom: 1px solid ${COLORS.border}; display: flex; flex-direction: column; overflow: hidden; min-width: 0; min-height: 0; position: relative; border-top: 5px solid #5f6673; }
|
|
.ult-section[data-sect="tool"] { border-top-color: #ff9f43; }
|
|
.ult-section[data-sect="style"] { border-top-color: #34d5eb; }
|
|
.ult-section[data-sect="layers"] { border-top-color: #b9c000; }
|
|
.ult-section.collapsed { flex: 0 0 auto !important; border-top-color: #666d78 !important; }
|
|
.ult-section.collapsed .ult-section-content { display: none; }
|
|
.ult-section-header { padding: 10px 12px 9px; background: #161a20; font-size: 11px; font-weight: 600; color: #b8bec8; text-transform: none; display: flex; justify-content: space-between; align-items: center; cursor: pointer; flex-shrink: 0; }
|
|
.ult-sect-title { display: inline-flex; align-items: center; gap: 8px; }
|
|
/* Vertical splitter: keep a larger hit area for easier dragging in ComfyUI */
|
|
.ult-resizer-v { height: 10px; background: transparent; cursor: row-resize; z-index: 20; flex-shrink: 0; margin-top: -4px; margin-bottom: -4px; touch-action: none; }
|
|
.ult-resizer-v::after { content: ""; display: block; height: 4px; margin: 3px 0; border-radius: 2px; background: transparent; transition: background 0.15s; }
|
|
.ult-resizer-v:hover::after { background: ${COLORS.accent}; }
|
|
.ult-section-content { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; overflow-x: hidden; flex: 1; min-width: 0; box-sizing: border-box; scrollbar-width: none; }
|
|
.ult-section-content::-webkit-scrollbar { width: 0; height: 0; }
|
|
.ult-tool-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 6px; margin-bottom: 6px; min-width: 0; }
|
|
|
|
.ult-tool-btn { aspect-ratio: 1; border: 1px solid #3b4350; border-radius: 6px; background: #222831; color: #c9d0dc; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 18px; transition: all 0.15s; min-width: 0; }
|
|
.ult-tool-btn:hover { background: #2a313b; border-color: #7e8797; }
|
|
.ult-tool-btn.active { background: #273447; color: #88c4ff; border-color: #66b4ff; box-shadow: inset 0 0 0 1px #66b4ff; }
|
|
.ult-tool-icon { width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; }
|
|
.ult-tool-icon svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.35; stroke-linecap: round; stroke-linejoin: round; }
|
|
|
|
.ult-action-row { display: flex; gap: 6px; }
|
|
.ult-action-btn { flex: 1; height: 28px; border: 1px solid #3b4350; border-radius: 4px; background: #222831; color: #b8c0cb; font-size: 11px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 4px; transition: all 0.2s; }
|
|
.ult-action-btn:hover { background: ${COLORS.hover}; color: ${COLORS.text}; }
|
|
.ult-action-btn:disabled { opacity: 0.3; cursor: not-allowed; pointer-events: none; }
|
|
.ult-action-btn.danger:hover { background: ${COLORS.danger}; color: #fff; border-color: ${COLORS.danger}; }
|
|
|
|
.ult-style-row { display: flex; align-items: center; gap: 8px; font-size: 12px; min-width: 0; }
|
|
.ult-style-row.param { display: grid; grid-template-columns: 64px minmax(0, 1fr) 56px; align-items: center; column-gap: 8px; }
|
|
.ult-label { width: 52px; color: ${COLORS.dim}; white-space: nowrap; line-height: 1.2; }
|
|
.ult-style-row.param .ult-label { width: auto; }
|
|
.ult-style-row.param .ult-slider { width: 100%; }
|
|
.ult-style-row.param .ult-num-input { justify-self: end; }
|
|
.ult-num-input.readonly { width: 42px; cursor: default; background: #171d26; border-color: #2e343d; pointer-events: none; }
|
|
|
|
/* AI Style Color Pickers */
|
|
.ult-ai-box-stack { position: relative; width: 40px; height: 40px; margin-right: 6px; flex-shrink: 0; }
|
|
.ult-ai-box { position: absolute; width: 30px; height: 30px; border: 2px solid #fff; border-radius: 4px; cursor: pointer; box-shadow: 0 0 4px rgba(0,0,0,0.5); transition: transform 0.1s; }
|
|
.ult-ai-box.fill { top: 0; left: 0; z-index: 2; }
|
|
.ult-ai-box.stroke { bottom: 0; right: 0; z-index: 1; }
|
|
.ult-ai-box.active { border-color: ${COLORS.accent}; z-index: 3; transform: scale(1.05); }
|
|
.ult-color-picker-hidden { width: 0; height: 0; visibility: hidden; position: absolute; }
|
|
|
|
.ult-quick-colors { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
|
|
.ult-q-color { width: 14px; height: 14px; border-radius: 3px; cursor: pointer; border: 1px solid rgba(255,255,255,0.15); }
|
|
.ult-q-color:hover { transform: scale(1.15); border-color: #fff; }
|
|
|
|
.ult-slider { flex: 1; min-width: 0; -webkit-appearance: none; height: 6px; background: #293142; border-radius: 3px; }
|
|
.ult-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #2ccf6f; border: 2px solid #122016; cursor: ew-resize; }
|
|
.ult-num-input { width: 56px; height: 24px; background: #1f2530; border: 1px solid #39414d; border-radius: 6px; color: #2ed773; font-family: 'JetBrains Mono', monospace; font-size: 11px; text-align: center; outline: none; flex-shrink: 0; }
|
|
.ult-num-input:focus { border-color: #66b4ff; }
|
|
|
|
.ult-checkbox-row { display: flex; gap: 12px; margin-top: 4px; }
|
|
.ult-checkbox { display: flex; align-items: center; gap: 4px; font-size: 11px; color: ${COLORS.dim}; cursor: pointer; }
|
|
.ult-list-wrapper { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
|
.ult-list { flex: 1; min-height: 0; overflow-y: auto !important; overflow-x: hidden; padding: 4px; min-width: 0; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; touch-action: pan-y; }
|
|
.ult-list::-webkit-scrollbar { width: 6px; }
|
|
.ult-list::-webkit-scrollbar-track { background: transparent; }
|
|
.ult-list::-webkit-scrollbar-thumb { background: #3a4150; border-radius: 3px; }
|
|
.ult-list::-webkit-scrollbar-thumb:hover { background: #4a5260; }
|
|
.ult-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 4px; background: ${COLORS.section}; border: 1px solid transparent; margin-bottom: 2px; cursor: pointer; min-width: 0; box-sizing: border-box; }
|
|
.ult-item.selected { background: rgba(34, 197, 94, 0.1); border-color: ${COLORS.accent}; }
|
|
.ult-vis { width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.85; border-radius: 4px; background: rgba(255,255,255,0.03); }
|
|
.ult-vis svg { width: 14px; height: 14px; stroke: #c9ced7; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
|
.ult-item.hidden .ult-vis { opacity: 0.45; }
|
|
.ult-item.hidden .ult-vis svg { stroke: #88919f; }
|
|
.ult-item-tag { flex: 1; min-width: 0; background: transparent; border: none; color: inherit; font-size: 12px; outline: none; }
|
|
.ult-mark-del { opacity: 0.3; transition: opacity 0.2s; padding: 4px; font-size: 14px; }
|
|
.ult-item:hover .ult-mark-del { opacity: 0.7; }
|
|
.ult-mark-del:hover { opacity: 1 !important; color: ${COLORS.danger}; }
|
|
|
|
.ult-main { flex: 1; display: flex; flex-direction: column; background: #1e1e1e; position: relative; }
|
|
.ult-status { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 6px 12px; background: #171b22; border-bottom: 1px solid #2e343d; font-size: 11px; white-space: nowrap; overflow: hidden; }
|
|
.ult-view { flex: 1; position: relative; overflow: hidden; }
|
|
.ult-canvas { width: 100%; height: 100%; display: block; image-rendering: -webkit-optimize-contrast; }
|
|
.ult-trash-all { cursor: pointer; opacity: 0.72; transition: all 0.2s; font-size: 20px; line-height: 1; }
|
|
.ult-trash-all:hover { opacity: 1; color: ${COLORS.danger}; transform: scale(1.1); }
|
|
.ult-list-actions { display: flex; gap: 8px; padding: 8px 12px 10px; border-top: 1px solid ${COLORS.border}; background: rgba(0,0,0,0.15); flex-shrink: 0; }
|
|
.ult-list-actions.hidden { display: none; }
|
|
.ult-list-btn { flex: 1; height: 30px; border: 1px solid ${COLORS.border}; border-radius: 8px; background: ${COLORS.section}; color: ${COLORS.dim}; font-size: 12px; cursor: pointer; transition: all 0.2s; }
|
|
.ult-list-btn:hover { background: ${COLORS.hover}; color: ${COLORS.text}; }
|
|
.ult-list-btn.danger:hover { background: ${COLORS.danger}; border-color: ${COLORS.danger}; color: #fff; }
|
|
`;
|
|
|
|
class UltimateEditor {
|
|
constructor(node, container) {
|
|
this.node = node; this.container = container;
|
|
this.marks = []; this.undoStack = []; this.tool = TOOLS.PEN;
|
|
this.style = { stroke: "#22c55e", fill: "#22c55e", width: 2, size: 12, alpha: 0.35, show_stroke: true, show_fill: true };
|
|
this.scale = 1.0; this.pan = { x: 0, y: 0 };
|
|
this.selectedIndices = new Set(); this.lastSelectedIdx = -1;
|
|
this.layoutStorageKey = "ult_image_annotator_layout_v1";
|
|
this._globalListeners = []; // Track global event listeners for cleanup
|
|
this.initUI();
|
|
}
|
|
|
|
// Helper: Add global event listener with tracking for cleanup
|
|
_addGlobalListener(target, event, handler, options) {
|
|
target.addEventListener(event, handler, options);
|
|
this._globalListeners.push({ target, event, handler, options });
|
|
}
|
|
|
|
// Helper: Unified localStorage operations
|
|
_layoutStorage = {
|
|
get: (key, defaultValue) => {
|
|
try {
|
|
const raw = localStorage.getItem(this.layoutStorageKey);
|
|
if (!raw) return defaultValue;
|
|
const parsed = JSON.parse(raw);
|
|
return parsed?.[key] ?? defaultValue;
|
|
} catch {
|
|
return defaultValue;
|
|
}
|
|
},
|
|
|
|
set: (key, value) => {
|
|
try {
|
|
const raw = localStorage.getItem(this.layoutStorageKey);
|
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
parsed[key] = value;
|
|
parsed.v = 1;
|
|
localStorage.setItem(this.layoutStorageKey, JSON.stringify(parsed));
|
|
} catch {}
|
|
},
|
|
|
|
getMultiple: (keys) => {
|
|
try {
|
|
const raw = localStorage.getItem(this.layoutStorageKey);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
const result = {};
|
|
keys.forEach(k => { result[k] = parsed?.[k]; });
|
|
return result;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
};
|
|
|
|
initUI() {
|
|
if (!document.getElementById("ult-editor-css")) {
|
|
const s = document.createElement("style"); s.id = "ult-editor-css"; s.innerHTML = CSS; document.head.appendChild(s);
|
|
}
|
|
this.container.className = "ult-editor";
|
|
this.container.innerHTML = `
|
|
<aside class="ult-sidebar" id="ult-side">
|
|
<div class="ult-resizer" id="ult-resize"></div>
|
|
<header class="ult-header">
|
|
<h1>Annotation Control Center</h1>
|
|
<div class="ult-header-info"><span>Count: <b id="ult-count">0</b></span></div>
|
|
</header>
|
|
<section class="ult-section" data-sect="tool" style="flex: 0 0 120px">
|
|
<div class="ult-section-header"><span class="ult-sect-title">Tools</span></div>
|
|
<div class="ult-section-content">
|
|
<div class="ult-tool-grid">
|
|
<button class="ult-tool-btn" data-tool="arrow" title="Select / Move (V)"><span class="ult-tool-icon">↖️</span></button>
|
|
<button class="ult-tool-btn" data-tool="move" title="Hand (H)"><span class="ult-tool-icon">✋</span></button>
|
|
<button class="ult-tool-btn" data-tool="pen" title="Point (P)"><span class="ult-tool-icon">⦿</span></button>
|
|
<button class="ult-tool-btn" data-tool="rect" title="Rectangle (M / R)"><span class="ult-tool-icon">▢</span></button>
|
|
<button class="ult-tool-btn" data-tool="lasso" title="Polygon Lasso (L)"><span class="ult-tool-icon">⬡</span></button>
|
|
</div>
|
|
<div class="ult-action-row">
|
|
<button class="ult-action-btn" id="ult-undo" title="Undo (Ctrl/Cmd+Z)">⬅️ Undo</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div class="ult-resizer-v" data-prev="tool"></div>
|
|
<section class="ult-section" data-sect="style" style="flex: 0 0 240px">
|
|
<div class="ult-section-header"><span class="ult-sect-title">Appearance / Properties</span></div>
|
|
<div class="ult-section-content">
|
|
<div class="ult-style-row" style="align-items: flex-start">
|
|
<div class="ult-ai-box-stack" id="ult-ai-colors">
|
|
<div class="ult-ai-box fill active" title="Fill color" style="background:#22c55e"></div>
|
|
<div class="ult-ai-box stroke" title="Stroke color" style="border-color:#22c55e"></div>
|
|
<input type="color" class="ult-color-picker-hidden" id="ult-pick-color">
|
|
</div>
|
|
<div class="ult-quick-colors">
|
|
<div class="ult-q-color" data-val="${COLORS.semantic.red}" style="background:${COLORS.semantic.red}"></div>
|
|
<div class="ult-q-color" data-val="${COLORS.semantic.green}" style="background:${COLORS.semantic.green}"></div>
|
|
<div class="ult-q-color" data-val="${COLORS.semantic.blue}" style="background:${COLORS.semantic.blue}"></div>
|
|
<div class="ult-q-color" data-val="#ffffff" style="background:#ffffff"></div>
|
|
<div class="ult-q-color" data-val="#000000" style="background:#000000"></div>
|
|
<div class="ult-q-color" data-val="#eab308" style="background:#eab308" title="Yellow"></div>
|
|
<div class="ult-q-color" data-val="#d946ef" style="background:#d946ef" title="Magenta"></div>
|
|
<div class="ult-q-color" data-val="#06b6d4" style="background:#06b6d4" title="Cyan"></div>
|
|
</div>
|
|
</div>
|
|
<div class="ult-style-row param">
|
|
<span class="ult-label">Stroke</span>
|
|
<input type="range" class="ult-slider" id="ult-s-width" min="0" max="20" value="2">
|
|
<input type="text" class="ult-num-input readonly" id="ult-n-width" value="2" readonly>
|
|
</div>
|
|
<div class="ult-style-row param">
|
|
<span class="ult-label">Size</span>
|
|
<input type="range" class="ult-slider" id="ult-s-size" min="1" max="100" value="12">
|
|
<input type="text" class="ult-num-input readonly" id="ult-n-size" value="12" readonly>
|
|
</div>
|
|
<div class="ult-style-row param">
|
|
<span class="ult-label">Fill Op.</span>
|
|
<input type="range" class="ult-slider" id="ult-s-alpha" min="0" max="100" value="35">
|
|
<input type="text" class="ult-num-input readonly" id="ult-n-alpha" value="35" readonly>
|
|
</div>
|
|
<div class="ult-checkbox-row">
|
|
<label class="ult-checkbox"><input type="checkbox" id="ult-chk-stroke" checked> Stroke</label>
|
|
<label class="ult-checkbox"><input type="checkbox" id="ult-chk-fill" checked> Fill</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<div class="ult-resizer-v" data-prev="style"></div>
|
|
<section class="ult-section" style="flex: 1" data-sect="layers">
|
|
<div class="ult-section-header"><span class="ult-sect-title">Annotations</span><span class="ult-trash-all" id="ult-clear-all" title="Clear all annotations">⟲</span></div>
|
|
<div class="ult-section-content" style="padding: 0;">
|
|
<div class="ult-list-wrapper">
|
|
<div class="ult-list" id="ult-list"></div>
|
|
<div class="ult-list-actions hidden" id="ult-list-actions">
|
|
<button class="ult-list-btn danger" id="ult-del-selected">Delete Selected</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</aside>
|
|
<main class="ult-main">
|
|
<div class="ult-status"><div>Tool: <b style="color:${COLORS.accent}" id="ult-st-tool">-</b> | Zoom: <b style="color:${COLORS.info}" id="ult-st-zoom">100%</b></div><b id="ult-st-pos">-, -</b></div>
|
|
<div class="ult-view"><canvas class="ult-canvas"></canvas></div>
|
|
</main>
|
|
`;
|
|
|
|
this.canvas = this.container.querySelector(".ult-canvas"); this.ctx = this.canvas.getContext("2d");
|
|
this.view = this.container.querySelector(".ult-view"); this.listEl = this.container.querySelector("#ult-list");
|
|
this.sidebar = this.container.querySelector("#ult-side");
|
|
this.resizer = this.container.querySelector("#ult-resize");
|
|
|
|
// Cache frequently used DOM elements
|
|
this.elements = {
|
|
stTool: this.container.querySelector("#ult-st-tool"),
|
|
stZoom: this.container.querySelector("#ult-st-zoom"),
|
|
stPos: this.container.querySelector("#ult-st-pos"),
|
|
count: this.container.querySelector("#ult-count"),
|
|
listActions: this.container.querySelector("#ult-list-actions"),
|
|
delSelected: this.container.querySelector("#ult-del-selected")
|
|
};
|
|
|
|
const q = (s) => this.container.querySelector(s);
|
|
const qa = (s) => this.container.querySelectorAll(s);
|
|
const clamp = (min, v, max) => Math.max(min, Math.min(v, max));
|
|
|
|
if (this.listEl) {
|
|
this.listEl.addEventListener("wheel", (e) => {
|
|
e.stopPropagation();
|
|
if (this.listEl.scrollHeight > this.listEl.clientHeight) {
|
|
this.listEl.scrollTop += e.deltaY;
|
|
e.preventDefault();
|
|
}
|
|
}, { passive: false });
|
|
}
|
|
|
|
// --- Layout persistence (sidebar section heights only) ---
|
|
const applySavedSectionHeights = () => {
|
|
const { tool_h_px, style_h_px } = this._layoutStorage.getMultiple(['tool_h_px', 'style_h_px']);
|
|
if (typeof tool_h_px === "number") {
|
|
const sect = q(`section[data-sect="tool"]`);
|
|
if (sect) sect.style.flex = `0 0 ${Math.max(60, Math.round(tool_h_px))}px`;
|
|
}
|
|
if (typeof style_h_px === "number") {
|
|
const sect = q(`section[data-sect="style"]`);
|
|
if (sect) sect.style.flex = `0 0 ${Math.max(80, Math.round(style_h_px))}px`;
|
|
}
|
|
};
|
|
const applySavedSidebarWidth = () => {
|
|
const sideW = this._layoutStorage.get('side_w_px');
|
|
if (typeof sideW === "number") this.sidebar.style.width = `${Math.round(sideW)}px`;
|
|
};
|
|
const persistSectionHeights = () => {
|
|
const toolSect = q(`section[data-sect="tool"]`);
|
|
const styleSect = q(`section[data-sect="style"]`);
|
|
if (!toolSect || !styleSect) return;
|
|
this._layoutStorage.set('tool_h_px', Math.round(toolSect.getBoundingClientRect().height));
|
|
this._layoutStorage.set('style_h_px', Math.round(styleSect.getBoundingClientRect().height));
|
|
};
|
|
const persistSidebarWidth = () => {
|
|
this._layoutStorage.set('side_w_px', Math.round(this.sidebar.getBoundingClientRect().width));
|
|
};
|
|
const getSectionMins = () => {
|
|
const getHeaderHeight = (sect) => {
|
|
const h = sect?.querySelector(".ult-section-header");
|
|
const r = h?.getBoundingClientRect?.();
|
|
return Math.max(28, Math.round(r?.height || 0));
|
|
};
|
|
const toolSect = q(`section[data-sect="tool"]`);
|
|
const styleSect = q(`section[data-sect="style"]`);
|
|
const layersSect = q(`section[data-sect="layers"]`);
|
|
if (!toolSect || !styleSect || !layersSect) return null;
|
|
return {
|
|
tool: toolSect.classList.contains("collapsed") ? getHeaderHeight(toolSect) : 90,
|
|
style: styleSect.classList.contains("collapsed") ? getHeaderHeight(styleSect) : 140,
|
|
layers: layersSect.classList.contains("collapsed") ? getHeaderHeight(layersSect) : 64,
|
|
toolSect, styleSect, layersSect,
|
|
};
|
|
};
|
|
const rebalanceSectionHeights = () => {
|
|
const mins = getSectionMins();
|
|
if (!mins) return;
|
|
const { tool, style, layers, toolSect, styleSect, layersSect } = mins;
|
|
const sidebarRect = this.sidebar.getBoundingClientRect();
|
|
const headerEl = this.sidebar.querySelector(".ult-header");
|
|
const headerH = headerEl ? Math.round(headerEl.getBoundingClientRect().height) : 0;
|
|
const resizerTotalH = [...this.sidebar.querySelectorAll(".ult-resizer-v")]
|
|
.reduce((sum, el) => sum + Math.round(el.getBoundingClientRect().height), 0);
|
|
const available = Math.max(0, Math.floor(sidebarRect.height - headerH - resizerTotalH));
|
|
if (available <= 0) return;
|
|
|
|
const desiredSectionHeight = (sect, fallbackMin) => {
|
|
if (sect.classList.contains("collapsed")) return fallbackMin;
|
|
const head = sect.querySelector(".ult-section-header");
|
|
const body = sect.querySelector(".ult-section-content");
|
|
const headH = Math.round(head?.getBoundingClientRect?.().height || 30);
|
|
const bodyNeed = Math.round(body?.scrollHeight || 0);
|
|
return Math.max(fallbackMin, headH + bodyNeed + 2);
|
|
};
|
|
|
|
let toolH = desiredSectionHeight(toolSect, tool);
|
|
let styleH = desiredSectionHeight(styleSect, style);
|
|
toolH = clamp(tool, toolH, Math.max(tool, available - style - layers));
|
|
styleH = clamp(style, styleH, Math.max(style, available - toolH - layers));
|
|
let layersH = available - toolH - styleH;
|
|
if (layersH < layers) {
|
|
let deficit = layers - layersH;
|
|
const styleSlack = Math.max(0, styleH - style);
|
|
const styleCut = Math.min(styleSlack, deficit);
|
|
styleH -= styleCut;
|
|
deficit -= styleCut;
|
|
const toolSlack = Math.max(0, toolH - tool);
|
|
const toolCut = Math.min(toolSlack, deficit);
|
|
toolH -= toolCut;
|
|
layersH = available - toolH - styleH;
|
|
}
|
|
toolSect.style.flex = `0 0 ${Math.round(toolH)}px`;
|
|
styleSect.style.flex = `0 0 ${Math.round(styleH)}px`;
|
|
if (!layersSect.classList.contains("collapsed")) layersSect.style.flex = "1 1 auto";
|
|
};
|
|
const getSidebarBounds = () => {
|
|
const min = 200;
|
|
const hardMax = 520;
|
|
const mainMin = 320;
|
|
const cW = Math.round(this.container.getBoundingClientRect().width || 0);
|
|
const dynamicMax = cW > 0 ? cW - mainMin : hardMax;
|
|
return { min, max: Math.max(min, Math.min(hardMax, dynamicMax)) };
|
|
};
|
|
const clampSidebarWidth = (nextWidth) => {
|
|
const { min, max } = getSidebarBounds();
|
|
const width = clamp(min, Math.round(nextWidth), max);
|
|
this.sidebar.style.width = `${width}px`;
|
|
return width;
|
|
};
|
|
applySavedSectionHeights();
|
|
applySavedSidebarWidth();
|
|
clampSidebarWidth(this.sidebar.getBoundingClientRect().width || 260);
|
|
rebalanceSectionHeights();
|
|
|
|
// --- Collapsible panel logic ---
|
|
qa(".ult-section-header").forEach(h => h.onclick = () => {
|
|
h.parentElement.classList.toggle("collapsed");
|
|
rebalanceSectionHeights();
|
|
persistSectionHeights();
|
|
this.render();
|
|
});
|
|
|
|
// --- Style property bindings (Slider + Read-only Number Display) ---
|
|
const bindPair = (rangeId, numId, key, isAlpha = false) => {
|
|
const r = q(rangeId), n = q(numId);
|
|
const min = Number(r.min ?? 0);
|
|
const max = Number(r.max ?? 100);
|
|
|
|
r.oninput = (e) => {
|
|
const val = Number(e.target.value);
|
|
n.value = String(val);
|
|
this.updateStyle(key, isAlpha ? (val / 100) : val);
|
|
};
|
|
};
|
|
bindPair("#ult-s-width", "#ult-n-width", "width");
|
|
bindPair("#ult-s-size", "#ult-n-size", "size");
|
|
bindPair("#ult-s-alpha", "#ult-n-alpha", "alpha", true);
|
|
|
|
q("#ult-chk-stroke").onclick = (e) => this.updateStyle('show_stroke', e.target.checked);
|
|
q("#ult-chk-fill").onclick = (e) => this.updateStyle('show_fill', e.target.checked);
|
|
|
|
// --- AI-style color picker logic ---
|
|
const colorInput = q("#ult-pick-color");
|
|
const fillBox = q(".ult-ai-box.fill");
|
|
const strokeBox = q(".ult-ai-box.stroke");
|
|
let activeColorType = "fill"; // "fill" or "stroke"
|
|
|
|
const setActiveBox = (type) => {
|
|
activeColorType = type;
|
|
fillBox.classList.toggle("active", type === "fill");
|
|
strokeBox.classList.toggle("active", type === "stroke");
|
|
};
|
|
const applyColor = (type, hex) => {
|
|
if (type === "fill") {
|
|
fillBox.style.background = hex;
|
|
this.updateStyle("fill", hex);
|
|
} else {
|
|
strokeBox.style.borderColor = hex;
|
|
this.updateStyle("stroke", hex);
|
|
}
|
|
};
|
|
|
|
fillBox.onclick = () => { if (activeColorType === "fill") colorInput.click(); else setActiveBox("fill"); };
|
|
strokeBox.onclick = () => { if (activeColorType === "stroke") colorInput.click(); else setActiveBox("stroke"); };
|
|
|
|
colorInput.oninput = (e) => applyColor(activeColorType, e.target.value);
|
|
|
|
qa(".ult-q-color").forEach(qc => qc.onclick = () => applyColor(activeColorType, qc.dataset.val));
|
|
|
|
// --- Other tool buttons ---
|
|
qa(".ult-tool-btn[data-tool]").forEach(b => b.onclick = () => this.setTool(b.dataset.tool));
|
|
q("#ult-undo").onclick = () => this.undo();
|
|
q("#ult-clear-all").onclick = (e) => { e.stopPropagation(); this.clearAll(); };
|
|
q("#ult-del-selected").onclick = () => this.deleteSelected();
|
|
|
|
// --- Vertical resize logic (Pointer Events + capture to prevent ComfyUI interference) ---
|
|
this._vResize = null; // { prevKey, prevSect, startY, startH, pointerId }
|
|
qa(".ult-resizer-v").forEach(rv => rv.addEventListener("pointerdown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const prevKey = rv.dataset.prev;
|
|
const prevSect = q(`section[data-sect="${prevKey}"]`);
|
|
if (!prevSect) return;
|
|
try { rv.setPointerCapture(e.pointerId); } catch { }
|
|
this._vResize = {
|
|
prevKey,
|
|
prevSect,
|
|
startY: e.clientY,
|
|
startH: Math.round(prevSect.getBoundingClientRect().height),
|
|
pointerId: e.pointerId,
|
|
};
|
|
this.container.classList.add("ult-resizing-v");
|
|
}));
|
|
|
|
const onVPointerMove = (e) => {
|
|
if (!this._vResize) return;
|
|
if (e.pointerId !== this._vResize.pointerId) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const { prevKey, prevSect, startY, startH } = this._vResize;
|
|
|
|
const sidebarRect = this.sidebar.getBoundingClientRect();
|
|
const headerEl = this.sidebar.querySelector(".ult-header");
|
|
const headerH = headerEl ? Math.round(headerEl.getBoundingClientRect().height) : 0;
|
|
const resizerTotalH = [...this.sidebar.querySelectorAll(".ult-resizer-v")]
|
|
.reduce((sum, el) => sum + Math.round(el.getBoundingClientRect().height), 0);
|
|
|
|
const mins = getSectionMins();
|
|
if (!mins) return;
|
|
const { tool: toolMin, style: styleMin, layers: layersMin } = mins;
|
|
|
|
const minForPrev = prevKey === "tool" ? toolMin : styleMin;
|
|
const maxForPrev = Math.max(
|
|
minForPrev,
|
|
Math.floor(sidebarRect.height - headerH - resizerTotalH - (prevKey === "tool" ? (styleMin + layersMin) : (toolMin + layersMin)))
|
|
);
|
|
|
|
const dy = e.clientY - startY;
|
|
const nextH = clamp(minForPrev, startH + dy, maxForPrev);
|
|
prevSect.style.flex = `0 0 ${Math.round(nextH)}px`;
|
|
rebalanceSectionHeights();
|
|
this.render();
|
|
};
|
|
|
|
const onVPointerUp = (e) => {
|
|
if (!this._vResize) return;
|
|
if (e.pointerId !== this._vResize.pointerId) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._vResize = null;
|
|
this.container.classList.remove("ult-resizing-v");
|
|
persistSectionHeights();
|
|
};
|
|
|
|
this._addGlobalListener(window, "pointermove", onVPointerMove, { capture: true });
|
|
this._addGlobalListener(window, "pointerup", onVPointerUp, { capture: true });
|
|
this._addGlobalListener(window, "pointercancel", onVPointerUp, { capture: true });
|
|
|
|
this._hResize = null; // { pointerId, startX, startWidth }
|
|
this.resizer.addEventListener("pointerdown", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try { this.resizer.setPointerCapture(e.pointerId); } catch { }
|
|
this._hResize = {
|
|
pointerId: e.pointerId,
|
|
startX: e.clientX,
|
|
startWidth: Math.round(this.sidebar.getBoundingClientRect().width),
|
|
};
|
|
this.container.classList.add("ult-resizing-h");
|
|
});
|
|
const onHPointerMove = (e) => {
|
|
if (!this._hResize) return;
|
|
if (e.pointerId !== this._hResize.pointerId) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const next = this._hResize.startWidth + (e.clientX - this._hResize.startX);
|
|
clampSidebarWidth(next);
|
|
this.render();
|
|
};
|
|
const onHPointerUp = (e) => {
|
|
if (!this._hResize) return;
|
|
if (e.pointerId !== this._hResize.pointerId) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
this._hResize = null;
|
|
this.container.classList.remove("ult-resizing-h");
|
|
persistSidebarWidth();
|
|
};
|
|
this._addGlobalListener(window, "pointermove", onHPointerMove, { capture: true });
|
|
this._addGlobalListener(window, "pointerup", onHPointerUp, { capture: true });
|
|
this._addGlobalListener(window, "pointercancel", onHPointerUp, { capture: true });
|
|
|
|
this.view.onmousedown = (e) => this.onMouseDown(e);
|
|
this.view.ondblclick = (e) => this.onDoubleClick(e);
|
|
this.keyboardScopeActive = false;
|
|
this.container.addEventListener("pointerenter", () => { this.keyboardScopeActive = true; });
|
|
this.container.addEventListener("pointerleave", () => { this.keyboardScopeActive = false; this.spaceDown = false; this.updateCursor(); });
|
|
this.container.addEventListener("pointerdown", () => { this.keyboardScopeActive = true; });
|
|
this._addGlobalListener(window, "contextmenu", (e) => {
|
|
if (this.isDrawing && this.tool === TOOLS.LASSO) {
|
|
e.preventDefault();
|
|
this.finishPolygon();
|
|
}
|
|
});
|
|
this._addGlobalListener(window, "mousemove", (e) => this.onMouseMove(e));
|
|
this._addGlobalListener(window, "mouseup", () => this.onMouseUp());
|
|
this.view.onwheel = (e) => this.onWheel(e);
|
|
|
|
this._addGlobalListener(window, "keydown", (e) => {
|
|
if (e.target.tagName === "INPUT") return;
|
|
if (!this.node.selected) return;
|
|
if (!this.keyboardScopeActive) return;
|
|
if (e.code === "Space") { e.preventDefault(); this.spaceDown = true; this.updateCursor(); return; }
|
|
if (e.code === "KeyA" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.marks.forEach((_, i) => this.selectedIndices.add(i)); this.refresh(); return; }
|
|
const k = e.key.toLowerCase();
|
|
if (k === "v") this.setTool(TOOLS.ARROW);
|
|
if (k === "h") this.setTool(TOOLS.MOVE);
|
|
if (k === "p") this.setTool(TOOLS.PEN);
|
|
if (k === "m" || k === "r") this.setTool(TOOLS.RECT);
|
|
if (k === "l") this.setTool(TOOLS.LASSO);
|
|
if (k === "z" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this.undo(); return; }
|
|
if (k === "delete" || k === "backspace") { e.preventDefault(); this.deleteSelected(); return; }
|
|
});
|
|
this._addGlobalListener(window, "keyup", (e) => { if (e.code === "Space") { this.spaceDown = false; this.updateCursor(); } });
|
|
this.container.onmousedown = (e) => e.stopPropagation();
|
|
this.container.onwheel = (e) => e.stopPropagation();
|
|
|
|
chainCallback(this.node, "onResize", () => {
|
|
clampSidebarWidth(this.sidebar.getBoundingClientRect().width || 260);
|
|
rebalanceSectionHeights();
|
|
this.render();
|
|
});
|
|
if (typeof ResizeObserver !== "undefined") {
|
|
this._layoutObserver = new ResizeObserver(() => {
|
|
clampSidebarWidth(this.sidebar.getBoundingClientRect().width || 260);
|
|
rebalanceSectionHeights();
|
|
this.render();
|
|
});
|
|
this._layoutObserver.observe(this.container);
|
|
}
|
|
this.setTool(TOOLS.PEN);
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
|
|
updateStyle(key, val) {
|
|
this.style[key] = val;
|
|
if (this.selectedIndices.size > 0) {
|
|
this.selectedIndices.forEach(idx => {
|
|
const m = this.marks[idx];
|
|
if (!m.style) m.style = {};
|
|
if (key === 'stroke') m.style.stroke_color = val;
|
|
else if (key === 'fill') m.style.fill_color = val;
|
|
else if (key === 'width') m.style.stroke_width = val;
|
|
else if (key === 'size') m.style.point_size = val;
|
|
else if (key === 'alpha') m.style.fill_alpha = val;
|
|
else if (key === 'show_stroke') m.style.show_stroke = val;
|
|
else if (key === 'show_fill') m.style.show_fill = val;
|
|
});
|
|
this.refresh();
|
|
}
|
|
}
|
|
|
|
clearAll() { if (confirm("Clear all annotations?")) { this.saveUndo(); this.marks = []; this.selectedIndices.clear(); this.refresh(); } }
|
|
|
|
setTool(t) {
|
|
this.tool = t;
|
|
this.container.querySelectorAll(".ult-tool-grid .ult-tool-btn").forEach(b => b.classList.toggle("active", b.dataset.tool === t));
|
|
this.elements.stTool.innerText = TOOL_NAMES[t] || t;
|
|
this.isDrawing = false;
|
|
this.updateCursor();
|
|
this.render();
|
|
}
|
|
updateCursor() { this.view.style.cursor = (this.spaceDown || this.tool === TOOLS.MOVE) ? "grab" : "crosshair"; }
|
|
setImage(url) { if (!url) return; const img = new Image(); img.crossOrigin = "anonymous"; img.onload = () => { this.img = img; this.fitView(); }; img.src = url; }
|
|
fitView() {
|
|
if (!this.img) return;
|
|
const v = this.view.getBoundingClientRect();
|
|
if (v.width === 0) { setTimeout(() => this.fitView(), 100); return; }
|
|
this.canvas.width = v.width; this.canvas.height = v.height;
|
|
const r = this.img.width / this.img.height;
|
|
let dw = v.width * 0.95, dh = v.height * 0.95;
|
|
if (dw / dh > r) dw = dh * r; else dh = dw / r;
|
|
this.scale = dw / this.img.width;
|
|
this.pan.x = (v.width - dw) / 2;
|
|
this.pan.y = (v.height - dh) / 2;
|
|
this.refresh();
|
|
this.updateZoomDisplay();
|
|
}
|
|
getCoord(e) {
|
|
const r = this.canvas.getBoundingClientRect();
|
|
let x = (e.clientX - r.left - this.pan.x) / this.scale;
|
|
let y = (e.clientY - r.top - this.pan.y) / this.scale;
|
|
if (this.img) {
|
|
x = Math.max(0, Math.min(x, this.img.width));
|
|
y = Math.max(0, Math.min(y, this.img.height));
|
|
}
|
|
return { x, y };
|
|
}
|
|
onWheel(e) {
|
|
e.preventDefault();
|
|
const delta = e.deltaY > 0 ? 0.9 : 1.1;
|
|
const oldScale = this.scale;
|
|
this.scale = Math.min(Math.max(this.scale * delta, 0.1), 10);
|
|
|
|
const pt = this.getCoord(e);
|
|
this.pan.x -= (pt.x * (this.scale - oldScale));
|
|
this.pan.y -= (pt.y * (this.scale - oldScale));
|
|
|
|
this.updateZoomDisplay();
|
|
this.render();
|
|
}
|
|
|
|
onMouseDown(e) {
|
|
const pt = this.getCoord(e); this.lastMouse = { x: e.clientX, y: e.clientY };
|
|
if (this.spaceDown || e.button === 1 || this.tool === TOOLS.MOVE) { this.isPanning = true; return; }
|
|
|
|
if (this.tool === TOOLS.ARROW) {
|
|
const hit = this.hitTest(pt);
|
|
if (hit.idx >= 0) {
|
|
this.saveUndo();
|
|
this.select(hit.idx, e);
|
|
this.isDragging = true;
|
|
this.dragInfo = { idx: hit.idx, handleIdx: hit.handleIdx };
|
|
} else if (!e.ctrlKey && !e.shiftKey && !e.metaKey) {
|
|
this.selectedIndices.clear(); this.refresh();
|
|
this.isPanning = true;
|
|
} else {
|
|
this.isPanning = true;
|
|
}
|
|
}
|
|
else if (this.tool === TOOLS.PEN) { this.saveUndo(); this.addMark("point", [[pt.x, pt.y]]); }
|
|
else if (this.tool === TOOLS.RECT) { this.isDrawing = true; this.tempPoints = [[pt.x, pt.y], [pt.x, pt.y]]; }
|
|
else if (this.tool === TOOLS.LASSO) {
|
|
if (!this.isDrawing) {
|
|
this.saveUndo();
|
|
this.isDrawing = true;
|
|
this.tempPoints = [];
|
|
}
|
|
this.tempPoints.push([pt.x, pt.y]);
|
|
this.render();
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
|
|
onMouseMove(e) {
|
|
const p = this.getCoord(e); this.elements.stPos.innerText = `${Math.round(p.x)}, ${Math.round(p.y)}`;
|
|
if (this.isPanning) { this.pan.x += e.clientX - this.lastMouse.x; this.pan.y += e.clientY - this.lastMouse.y; this.lastMouse = { x: e.clientX, y: e.clientY }; this.render(); return; }
|
|
if (this.isDragging && this.selectedIndices.size > 0) {
|
|
const dx = (e.clientX - this.lastMouse.x) / this.scale;
|
|
const dy = (e.clientY - this.lastMouse.y) / this.scale;
|
|
this.selectedIndices.forEach(idx => {
|
|
const m = this.marks[idx];
|
|
if (this.dragInfo && this.dragInfo.handleIdx !== -1 && idx === this.dragInfo.idx) {
|
|
const np = m.points[this.dragInfo.handleIdx];
|
|
m.points[this.dragInfo.handleIdx] = [np[0] + dx, np[1] + dy];
|
|
} else {
|
|
m.points = m.points.map(pt => [pt[0] + dx, pt[1] + dy]);
|
|
}
|
|
// Constrain points within image bounds
|
|
if (this.img) {
|
|
m.points.forEach(pt => {
|
|
pt[0] = Math.max(0, Math.min(pt[0], this.img.width));
|
|
pt[1] = Math.max(0, Math.min(pt[1], this.img.height));
|
|
});
|
|
}
|
|
});
|
|
this.lastMouse = { x: e.clientX, y: e.clientY }; this.render();
|
|
} else if (this.isDrawing) {
|
|
const pt = this.getCoord(e);
|
|
if (this.tool === TOOLS.RECT) this.tempPoints[1] = [pt.x, pt.y];
|
|
else if (this.tool === TOOLS.LASSO) {
|
|
this.tempPointsCurrent = [pt.x, pt.y];
|
|
}
|
|
this.render();
|
|
}
|
|
}
|
|
onMouseUp() {
|
|
this.isPanning = false;
|
|
if (this.isDragging) { this.isDragging = false; this.sync(); }
|
|
if (this.isDrawing) {
|
|
if (this.tool === TOOLS.RECT) {
|
|
const [p1, p2] = this.tempPoints;
|
|
const w = Math.abs(p2[0] - p1[0]);
|
|
const h = Math.abs(p2[1] - p1[1]);
|
|
// Only create rectangle if it has minimum size (5 pixels)
|
|
if (w > 5 && h > 5) {
|
|
this.saveUndo();
|
|
this.addMark("rect", this.tempPoints);
|
|
}
|
|
this.isDrawing = false;
|
|
} else if (this.tool === TOOLS.LASSO) {
|
|
// For lasso, points are added on mousedown, not mouseup.
|
|
// MouseUp only ends dragging or panning.
|
|
}
|
|
}
|
|
this.render();
|
|
}
|
|
|
|
addMark(t, p) {
|
|
const s = {
|
|
stroke_color: this.style.stroke, stroke_width: this.style.width,
|
|
fill_color: this.style.fill, fill_alpha: this.style.alpha,
|
|
point_size: this.style.size || 12,
|
|
show_stroke: this.style.show_stroke ?? true, show_fill: this.style.show_fill ?? true
|
|
};
|
|
this.marks.push({ label: `${this.marks.length + 1} `, type: t, points: JSON.parse(JSON.stringify(p)), style: s, visible: true });
|
|
this.select(this.marks.length - 1);
|
|
this.sync();
|
|
}
|
|
select(i, e = {}) {
|
|
if (i < 0) { this.selectedIndices.clear(); }
|
|
else if (e.ctrlKey || e.metaKey) {
|
|
if (this.selectedIndices.has(i)) this.selectedIndices.delete(i);
|
|
else this.selectedIndices.add(i);
|
|
} else if (e.shiftKey && this.lastSelectedIdx >= 0) {
|
|
const start = Math.min(i, this.lastSelectedIdx), end = Math.max(i, this.lastSelectedIdx);
|
|
for (let k = start; k <= end; k++) this.selectedIndices.add(k);
|
|
} else {
|
|
this.selectedIndices.clear(); this.selectedIndices.add(i);
|
|
}
|
|
if (i >= 0) { this.lastSelectedIdx = i; this.syncUI(this.marks[i]); }
|
|
this.updateList(); this.render();
|
|
}
|
|
// Helper: Update slider and number input pair
|
|
_updateSliderPair(sliderId, inputId, value) {
|
|
const slider = this.container.querySelector(sliderId);
|
|
const input = this.container.querySelector(inputId);
|
|
if (slider) slider.value = value;
|
|
if (input) input.value = value;
|
|
}
|
|
|
|
syncUI(m) {
|
|
const style = m.style || {};
|
|
this.style = {
|
|
stroke: style.stroke_color || "#22c55e",
|
|
fill: style.fill_color || style.stroke_color || "#22c55e",
|
|
width: style.stroke_width || 2,
|
|
size: style.point_size || 12,
|
|
alpha: style.fill_alpha ?? 0.35,
|
|
show_stroke: style.show_stroke ?? true,
|
|
show_fill: style.show_fill ?? true
|
|
};
|
|
|
|
const q = (s) => this.container.querySelector(s);
|
|
|
|
// Update color boxes
|
|
q(".ult-ai-box.fill").style.background = this.style.fill;
|
|
q(".ult-ai-box.stroke").style.borderColor = this.style.stroke;
|
|
|
|
// Update sliders and inputs
|
|
this._updateSliderPair("#ult-s-width", "#ult-n-width", this.style.width);
|
|
this._updateSliderPair("#ult-s-size", "#ult-n-size", this.style.size);
|
|
this._updateSliderPair("#ult-s-alpha", "#ult-n-alpha", Math.round(this.style.alpha * 100));
|
|
|
|
// Update checkboxes
|
|
q("#ult-chk-stroke").checked = this.style.show_stroke;
|
|
q("#ult-chk-fill").checked = this.style.show_fill;
|
|
}
|
|
deleteSelected() {
|
|
if (this.selectedIndices.size > 0) {
|
|
this.removeMarksByIndices(Array.from(this.selectedIndices));
|
|
}
|
|
}
|
|
removeMarksByIndices(indices = []) {
|
|
const valid = [...new Set(indices)]
|
|
.filter(idx => Number.isInteger(idx) && idx >= 0 && idx < this.marks.length)
|
|
.sort((a, b) => b - a);
|
|
if (valid.length === 0) return;
|
|
this.saveUndo();
|
|
valid.forEach(idx => this.marks.splice(idx, 1));
|
|
this.selectedIndices.clear();
|
|
this.lastSelectedIdx = -1;
|
|
this.refresh();
|
|
}
|
|
hitTest(pt) {
|
|
const HANDLE_RADIUS = 12 / this.scale;
|
|
for (let i = this.marks.length - 1; i >= 0; i--) {
|
|
const m = this.marks[i]; if (!m.visible) continue;
|
|
// Vertex detection first (Polygon Handle)
|
|
if (m.type === "polygon" || m.type === "rect") {
|
|
for (let pIdx = 0; pIdx < m.points.length; pIdx++) {
|
|
if (Math.hypot(pt.x - m.points[pIdx][0], pt.y - m.points[pIdx][1]) < HANDLE_RADIUS) {
|
|
return { idx: i, handleIdx: pIdx };
|
|
}
|
|
}
|
|
}
|
|
// Overall collision detection
|
|
if (m.type === "point") {
|
|
const radius = (m.style.point_size || 12);
|
|
if (Math.hypot(pt.x - m.points[0][0], pt.y - m.points[0][1]) < radius) return { idx: i, handleIdx: -1 };
|
|
}
|
|
else if (m.type === "rect") {
|
|
const [p1, p2] = m.points;
|
|
if (pt.x >= Math.min(p1[0], p2[0]) && pt.x <= Math.max(p1[0], p2[0]) && pt.y >= Math.min(p1[1], p2[1]) && pt.y <= Math.max(p1[1], p2[1])) return { idx: i, handleIdx: -1 };
|
|
}
|
|
else if (m.type === "polygon") {
|
|
let inside = false;
|
|
for (let p = 0, j = m.points.length - 1; p < m.points.length; j = p++) {
|
|
if (((m.points[p][1] > pt.y) !== (m.points[j][1] > pt.y)) && (pt.x < (m.points[j][0] - m.points[p][0]) * (pt.y - m.points[p][1]) / (m.points[j][1] - m.points[p][1]) + m.points[p][0])) inside = !inside;
|
|
}
|
|
if (inside) return { idx: i, handleIdx: -1 };
|
|
}
|
|
}
|
|
return { idx: -1, handleIdx: -1 };
|
|
}
|
|
|
|
// Helper method: Draw mark outline (used by selection highlight and temp drawing)
|
|
_drawMarkOutline(mark, radius, offset = 0) {
|
|
this.ctx.beginPath();
|
|
if (mark.type === "point") {
|
|
this.ctx.arc(mark.points[0][0], mark.points[0][1], radius + offset / this.scale, 0, Math.PI * 2);
|
|
} else if (mark.type === "rect") {
|
|
const [p1, p2] = mark.points;
|
|
const x = Math.min(p1[0], p2[0]), y = Math.min(p1[1], p2[1]);
|
|
const w = Math.abs(p2[0] - p1[0]), h = Math.abs(p2[1] - p1[1]);
|
|
this.ctx.rect(x - offset / this.scale, y - offset / this.scale, w + 2 * offset / this.scale, h + 2 * offset / this.scale);
|
|
} else if (mark.type === "polygon") {
|
|
this.ctx.moveTo(mark.points[0][0], mark.points[0][1]);
|
|
mark.points.forEach(p => this.ctx.lineTo(p[0], p[1]));
|
|
this.ctx.closePath();
|
|
}
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
// Helper method: Draw selection highlight (marching ants effect)
|
|
drawSelectionHighlight(mark, radius) {
|
|
this.ctx.save();
|
|
// Draw black outline first for visibility
|
|
this.ctx.strokeStyle = "#000";
|
|
this.ctx.lineWidth = 4 / this.scale;
|
|
this.ctx.setLineDash([]);
|
|
this._drawMarkOutline(mark, radius, 3);
|
|
|
|
// Draw white dashed line on top
|
|
this.ctx.strokeStyle = "#fff";
|
|
this.ctx.lineWidth = 2 / this.scale;
|
|
this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
|
|
this._drawMarkOutline(mark, radius, 3);
|
|
this.ctx.restore();
|
|
}
|
|
|
|
// Helper method: Draw temp shape outline
|
|
_drawTempShapeOutline() {
|
|
this.ctx.beginPath();
|
|
if (this.tool === TOOLS.RECT) {
|
|
this.ctx.rect(
|
|
this.tempPoints[0][0],
|
|
this.tempPoints[0][1],
|
|
this.tempPoints[1][0] - this.tempPoints[0][0],
|
|
this.tempPoints[1][1] - this.tempPoints[0][1]
|
|
);
|
|
} else if (this.tool === TOOLS.LASSO && this.tempPoints.length > 0) {
|
|
this.ctx.moveTo(this.tempPoints[0][0], this.tempPoints[0][1]);
|
|
this.tempPoints.forEach(p => this.ctx.lineTo(p[0], p[1]));
|
|
if (this.tempPointsCurrent) {
|
|
this.ctx.lineTo(this.tempPointsCurrent[0], this.tempPointsCurrent[1]);
|
|
}
|
|
}
|
|
this.ctx.stroke();
|
|
}
|
|
|
|
// Helper method: Draw temporary shape being drawn
|
|
drawTempShape() {
|
|
if (!this.isDrawing) return;
|
|
|
|
// Draw black outline first
|
|
this.ctx.strokeStyle = "#000";
|
|
this.ctx.lineWidth = 3 / this.scale;
|
|
this.ctx.setLineDash([]);
|
|
this._drawTempShapeOutline();
|
|
|
|
// Draw white dashed line on top
|
|
this.ctx.strokeStyle = "#fff";
|
|
this.ctx.lineWidth = 1.5 / this.scale;
|
|
this.ctx.setLineDash([5 / this.scale, 5 / this.scale]);
|
|
this._drawTempShapeOutline();
|
|
|
|
// Draw polygon vertices
|
|
if (this.tool === TOOLS.LASSO && this.tempPoints.length > 0) {
|
|
this.ctx.fillStyle = "#fff";
|
|
this.tempPoints.forEach(p => {
|
|
this.ctx.beginPath();
|
|
this.ctx.arc(p[0], p[1], 3 / this.scale, 0, Math.PI * 2);
|
|
this.ctx.fill();
|
|
});
|
|
}
|
|
}
|
|
|
|
render() {
|
|
if (!this.img) return; const rect = this.view.getBoundingClientRect(); if (rect.width === 0) return;
|
|
if (this.canvas.width !== rect.width || this.canvas.height !== rect.height) { this.canvas.width = rect.width; this.canvas.height = rect.height; }
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.save();
|
|
this.ctx.translate(this.pan.x, this.pan.y); this.ctx.scale(this.scale, this.scale);
|
|
this.ctx.drawImage(this.img, 0, 0);
|
|
this.marks.forEach((m, idx) => {
|
|
if (!m.visible) return;
|
|
const isSel = this.selectedIndices.has(idx);
|
|
const style = m.style || {};
|
|
const showStroke = style.show_stroke ?? true;
|
|
const showFill = style.show_fill ?? true;
|
|
const strokeWidth = (style.stroke_width || 2);
|
|
const radius = (style.point_size || 12);
|
|
|
|
this.ctx.lineWidth = strokeWidth / this.scale;
|
|
this.ctx.strokeStyle = style.stroke_color || COLORS.accent;
|
|
this.ctx.fillStyle = hexToRgba(style.fill_color || style.stroke_color || COLORS.accent, style.fill_alpha ?? 0.35);
|
|
|
|
let lx, ly; // Label position
|
|
|
|
if (m.type === "point") {
|
|
lx = m.points[0][0]; ly = m.points[0][1];
|
|
this.ctx.beginPath(); this.ctx.arc(lx, ly, radius, 0, Math.PI * 2);
|
|
if (showFill) this.ctx.fill();
|
|
if (showStroke) this.ctx.stroke();
|
|
} else if (m.type === "rect") {
|
|
const [p1, p2] = m.points;
|
|
const x = Math.min(p1[0], p2[0]), y = Math.min(p1[1], p2[1]);
|
|
const w = Math.abs(p2[0] - p1[0]), h = Math.abs(p2[1] - p1[1]);
|
|
lx = x + w / 2; ly = y + h / 2;
|
|
if (showFill) this.ctx.fillRect(x, y, w, h);
|
|
if (showStroke) this.ctx.strokeRect(x, y, w, h);
|
|
} else if (m.type === "polygon") {
|
|
this.ctx.beginPath(); this.ctx.moveTo(m.points[0][0], m.points[0][1]);
|
|
m.points.forEach(p => this.ctx.lineTo(p[0], p[1])); this.ctx.closePath();
|
|
if (showFill) this.ctx.fill();
|
|
if (showStroke) this.ctx.stroke();
|
|
let sx = 0, sy = 0; m.points.forEach(p => { sx += p[0]; sy += p[1]; });
|
|
lx = sx / m.points.length; ly = sy / m.points.length;
|
|
}
|
|
|
|
// Draw index number (Refined Design)
|
|
this.ctx.save();
|
|
this.ctx.fillStyle = "#fff"; this.ctx.textAlign = "center"; this.ctx.textBaseline = "middle";
|
|
this.ctx.shadowBlur = 4; this.ctx.shadowColor = "rgba(0,0,0,0.8)";
|
|
if (m.type === "point") {
|
|
const badgeR = Math.max(8, radius * 0.85);
|
|
this.ctx.beginPath(); this.ctx.arc(lx, ly, badgeR, 0, Math.PI * 2);
|
|
this.ctx.fillStyle = style.stroke_color || COLORS.accent;
|
|
this.ctx.fill();
|
|
this.ctx.strokeStyle = "#fff"; this.ctx.lineWidth = 1 / this.scale; this.ctx.stroke();
|
|
this.ctx.fillStyle = "#fff"; this.ctx.font = `bold ${badgeR * 1.1}px sans-serif`;
|
|
this.ctx.fillText(idx + 1, lx, ly);
|
|
} else {
|
|
// Rectangle and polygon show centered number only
|
|
const fontSize = Math.max(10, radius * 1.2);
|
|
this.ctx.font = `bold ${fontSize}px sans-serif`;
|
|
this.ctx.fillText(idx + 1, lx, ly);
|
|
}
|
|
this.ctx.restore();
|
|
|
|
// Selection highlight effect
|
|
if (isSel) {
|
|
this.drawSelectionHighlight(m, radius);
|
|
}
|
|
});
|
|
if (this.isDrawing) {
|
|
this.drawTempShape();
|
|
}
|
|
this.ctx.restore();
|
|
}
|
|
|
|
onDoubleClick() {
|
|
if (this.isDrawing && this.tool === TOOLS.LASSO) {
|
|
this.finishPolygon();
|
|
}
|
|
}
|
|
|
|
finishPolygon() {
|
|
if (this.tempPoints.length > 2) {
|
|
this.addMark("polygon", this.tempPoints);
|
|
}
|
|
// Reset all drawing state
|
|
this.isDrawing = false;
|
|
this.tempPoints = [];
|
|
this.tempPointsCurrent = null;
|
|
|
|
// Force switch to arrow tool - this will also call render()
|
|
this.setTool(TOOLS.ARROW);
|
|
}
|
|
updateList() {
|
|
if (!this.listEl) return;
|
|
this.listEl.innerHTML = "";
|
|
this.elements.count.innerText = this.marks.length;
|
|
const actionWrap = this.elements.listActions;
|
|
const delSelectedBtn = this.elements.delSelected;
|
|
const eyeIcon = (isVisible) => isVisible
|
|
? `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1.5 12s3.8-6 10.5-6 10.5 6 10.5 6-3.8 6-10.5 6S1.5 12 1.5 12z"></path><circle cx="12" cy="12" r="3.2"></circle></svg>`
|
|
: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1.5 12s3.8-6 10.5-6 10.5 6 10.5 6-3.8 6-10.5 6S1.5 12 1.5 12z"></path><path d="M4 20L20 4"></path></svg>`;
|
|
if (actionWrap && delSelectedBtn) {
|
|
const selectedCount = this.selectedIndices.size;
|
|
const show = selectedCount > 1;
|
|
actionWrap.classList.toggle("hidden", !show);
|
|
delSelectedBtn.innerText = `Delete Selected (${selectedCount})`;
|
|
}
|
|
[...this.marks].reverse().forEach((m, revIdx) => {
|
|
const i = this.marks.length - 1 - revIdx;
|
|
const item = document.createElement("div"); item.className = `ult-item ${this.selectedIndices.has(i) ? 'selected' : ''} ${!m.visible ? 'hidden' : ''}`;
|
|
item.innerHTML = `<div class="ult-vis">${eyeIcon(!!m.visible)}</div><div style="width:12px; height:12px; background:${m.style.stroke_color}; border-radius:3px; margin-right:8px; border:1px solid rgba(255,255,255,0.2)"></div><span style="font-size:10px; opacity:0.5; margin-right:4px">${i + 1}</span><input class="ult-item-tag" value="${m.label}"/><div class="ult-mark-del">🗑</div>`;
|
|
item.querySelector(".ult-vis").onclick = (e) => { e.stopPropagation(); m.visible = !m.visible; this.refresh(); };
|
|
item.querySelector(".ult-item-tag").onchange = (e) => { m.label = e.target.value; this.sync(); };
|
|
item.querySelector(".ult-mark-del").onclick = (e) => {
|
|
e.stopPropagation();
|
|
if (this.selectedIndices.size > 1 && this.selectedIndices.has(i)) {
|
|
this.removeMarksByIndices(Array.from(this.selectedIndices));
|
|
return;
|
|
}
|
|
this.removeMarksByIndices([i]);
|
|
};
|
|
item.onclick = (e) => this.select(i, e);
|
|
this.listEl.appendChild(item);
|
|
});
|
|
}
|
|
saveUndo() {
|
|
this.undoStack.push(JSON.stringify(this.marks));
|
|
if (this.undoStack.length > 50) this.undoStack.shift();
|
|
this.updateUndoRedoButtons();
|
|
}
|
|
updateUndoRedoButtons() {
|
|
const undoBtn = this.container.querySelector("#ult-undo");
|
|
if (undoBtn) undoBtn.disabled = (this.undoStack.length === 0);
|
|
}
|
|
undo() {
|
|
if (this.undoStack.length === 0) return;
|
|
this.marks = JSON.parse(this.undoStack.pop());
|
|
this.selectedIndices.clear();
|
|
this.lastSelectedIdx = -1;
|
|
this.refresh();
|
|
}
|
|
refresh() { this.render(); this.updateList(); this.sync(); this.updateZoomDisplay(); this.updateUndoRedoButtons(); }
|
|
updateZoomDisplay() {
|
|
const z = Math.round(this.scale * 100);
|
|
this.elements.stZoom.innerText = `${z}% `;
|
|
}
|
|
sync() {
|
|
const widget = this.node.widgets.find(w => w.name === "mark_data");
|
|
const data = { version: "1.0", marks: this.marks, image_size: [this.img?.width || 0, this.img?.height || 0] };
|
|
if (widget) { widget.value = JSON.stringify(data); this.node.graph.set_dirty_canvas(true); }
|
|
}
|
|
loadData() { const w = this.node.widgets.find(w => w.name === "mark_data"); if (w && w.value) { try { this.marks = JSON.parse(w.value).marks || []; this.refresh(); } catch (e) { } } }
|
|
|
|
// Cleanup method to prevent memory leaks
|
|
destroy() {
|
|
// Remove all global event listeners
|
|
this._globalListeners.forEach(({ target, event, handler, options }) => {
|
|
target.removeEventListener(event, handler, options);
|
|
});
|
|
this._globalListeners = [];
|
|
|
|
// Disconnect ResizeObserver
|
|
if (this._layoutObserver) {
|
|
this._layoutObserver.disconnect();
|
|
this._layoutObserver = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
app.registerExtension({
|
|
name: "Comfy.ImageAnnotatorUltimateV8.0",
|
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
|
if (nodeData.name !== "ImageAnnotator") return;
|
|
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
|
const c = document.createElement("div"); this.annotator = new UltimateEditor(this, c);
|
|
const mw = this.widgets.find(w => w.name === "mark_data"); if (mw) { mw.type = "converted-widget"; mw.computeSize = () => [0, -4]; }
|
|
this.addDOMWidget("ImageAnnotator", "editor", c, { serialize: false, hideOnZoom: false });
|
|
this.setSize([950, 750]); setTimeout(() => { this.annotator.loadData(); this.annotator.setImage(this.getImgUrl()); }, 150);
|
|
});
|
|
nodeType.prototype.getImgUrl = function () {
|
|
const l = this.inputs[0]?.link; if (!l) return null; const on = app.graph.getNodeById(app.graph.links[l].origin_id); if (!on) return null;
|
|
if (on.imgs && on.imgs.length > 0) return on.imgs[0].src; const fn = on.widgets?.find(w => w.name === "image")?.value || on.widgets_values?.[0];
|
|
return fn ? api.apiURL(`/view?filename=${encodeURIComponent(fn)}`) : null;
|
|
};
|
|
chainCallback(nodeType.prototype, "onConnectionsChange", function () { if (this.annotator) setTimeout(() => this.annotator.setImage(this.getImgUrl()), 250); });
|
|
chainCallback(nodeType.prototype, "onRemoved", function () {
|
|
if (this.annotator) {
|
|
this.annotator.destroy();
|
|
this.annotator = null;
|
|
}
|
|
});
|
|
}
|
|
});
|