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 = `
Tool: - | Zoom: 100%
-, -
`; 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 ? `` : ``; 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 = `
${eyeIcon(!!m.visible)}
${i + 1}
🗑
`; 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; } }); } });