diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md
index cf3a759..a80e321 100644
--- a/INSTRUCTIONS.md
+++ b/INSTRUCTIONS.md
@@ -1,8 +1,6 @@
# IAMCCS Annotate — User Guide
-Version: 1.0.0
-
-Coming soon: 2.0.0
+Version: 2.0.0
A fast, reliable annotation layer for ComfyUI. Draw, erase, organize in layers, and save notes inside your workflow.
@@ -13,6 +11,9 @@ A fast, reliable annotation layer for ComfyUI. Draw, erase, organize in layers,
- Layers with visibility/lock/rename/delete
- Per-mode brush sizes (draw vs eraser), opacity, dashed lines, constant width
- HiDPI ×2 toggle for higher-quality rendering
+- Select / Transform / Rotate tools (drag-to-select, then act on corners)
+- Screenshot Post-its (drag to capture, then move from edges; can be moved even when Annotate is OFF)
+- Pin/unpin mode for Post-its (lock a screenshot so it can't be moved/resized/rotated)
- Hide/show all annotations quickly
- Export/import your entire workflow + annotations
- Undo/redo and copy/paste for layers
@@ -31,8 +32,21 @@ A fast, reliable annotation layer for ComfyUI. Draw, erase, organize in layers,
- Alt+A — Toggle annotation mode
- Alt+D — Toggle eraser/draw
- Alt+S — Hide/show all annotations
-- Alt+F — Export workflow + notes (JSON)
+- Alt+1 — Tool: Draw
+- Alt+2 — Tool: Select
+- Alt+3 — Tool: Transform
+- Alt+4 — Tool: Rotate
+- Alt+5 — Tool: Screenshot
+- Alt+P — Toggle Pin/unpin mode (click a post-it to pin/unpin)
- Ctrl+I — Import workflow + notes (JSON)
+- Ctrl+C — Copy selection (Select tool)
+- Ctrl+X — Cut selection (Select tool)
+- Ctrl+V — Paste selection (Select tool)
+- Delete / Backspace — Delete current selection (any tool)
+- Esc — Cancel current stroke / clear current selection
+
+Notes:
+- Tool shortcuts (Alt+1..5) and Alt+P are active when Annotate is ON.
Note: On macOS, Alt refers to Option.
@@ -46,6 +60,37 @@ Note: On macOS, Alt refers to Option.
- HiDPI ×2: supersamples annotation rendering for crisper lines (uses more memory)
- Draw above nodes: when enabled, notes appear in the foreground; disable to push them behind nodes
+## Tools
+
+### Select
+- Drag to create a selection (rectangle or lasso)
+- The big selection frame is shown only while dragging; after release, selected items are highlighted
+- Drag inside a selected group to move it
+
+### Transform
+- Drag a corner handle to scale the selected items
+- Corners have a larger hit-area and visible handles for easier grabbing
+
+### Rotate
+- Drag a corner handle to rotate the selected items
+
+### Screenshot (Post-it)
+- Drag a rectangle to capture a screenshot of the visible canvas
+- Move a post-it by dragging any edge
+- Move still works even when Annotate is OFF
+
+### Text (+Text)
+- Click **+ Text** (next to **+ Layer**) to enter Text mode
+- Drag a rectangle on the workflow to set the text box size, then start typing
+- A font dropdown + size input appear next to **+ Text** in the menu
+- Double-click a text box to edit it later
+- Text boxes behave like a single object for Select / Transform / Rotate
+
+### Pin/unpin (Post-it lock)
+- Enable Pin/unpin mode (Alt+P or the checkbox)
+- Click a post-it to toggle a red pin icon
+- Pinned post-its cannot be moved/resized/rotated (even if selected)
+
## Layers
- + Layer: adds a new layer and selects it
@@ -55,22 +100,27 @@ Note: On macOS, Alt refers to Option.
- Lock icon: prevent edits on the layer
- X: delete layer (at least one layer is always kept)
+### Layer color preview
+
+- Each layer stores its own drawing color. When you select a layer, the color picker preview updates immediately to that layer’s saved color.
+
## Saving and persistence
-- Saves to workflow.extra.iamccs_annotations
+- Local autosave keeps deletes/edits immediately (survives refresh).
+- If **Save into WF** is enabled, annotations are also embedded into `workflow.extra.iamccs_annotations` (so they travel with the workflow JSON).
- Automatically saves when you:
- complete a stroke
- paste
- clear
- import
- change key settings
-- When you load a workflow, its annotations load automatically
-- Each workflow keeps its own annotations
+- When you load a workflow, embedded annotations load automatically
+- Each workflow/subgraph keeps its own annotations
## Export / Import
-- Export (Alt+F): downloads a JSON of the full workflow with annotations embedded in `extra.iamccs_annotations`
-- Import (ctrl+I): loads the full workflow if present; otherwise imports annotations only
+- Export (menu): use **Export Workflow+Notes** to download a JSON of the full workflow with annotations embedded in `extra.iamccs_annotations`
+- Import (Ctrl+I): loads the full workflow if present; otherwise imports annotations only
- Import JSON (menu): imports annotations into the current layer
## Troubleshooting
diff --git a/README.md b/README.md
index 107b24d..6222a9a 100644
--- a/README.md
+++ b/README.md
@@ -2,11 +2,11 @@
-Version: 1.0.0 — Coming soon: 2.0.0 (many new features!)
+Version: 2.0.0
-A powerful drawing and annotation tool for ComfyUI that lets you add notes, sketches, and annotations directly on your workflow canvas with full layer support.
+#A powerful drawing and annotation tool for ComfyUI that lets you add notes, sketches, and annotations directly on your workflow canvas with full layer support.
-
+
## Features
@@ -19,6 +19,7 @@ A powerful drawing and annotation tool for ComfyUI that lets you add notes, sket
- Constant width option (maintains width at any zoom level)
- HiDPI ×2 rendering toggle for crisper strokes
- pen-only mode (4 tablet use)
+- Pin/unpin mode for screenshot post-its
🎨 **Layer System**
- Create unlimited layers for organized annotations
@@ -28,14 +29,44 @@ A powerful drawing and annotation tool for ComfyUI that lets you add notes, sket
- Delete layers (minimum 1 layer always kept)
- Auto-selects new layer as active
+🧩 **Subgraph-aware Annotations (NEW in 2.0.0)**
+- Separate drawings per graph/subgraph: entering a subgraph shows its own annotations only
+- Returning to the main workflow restores the main workflow annotations
+
+🧰 **Selection Tools (NEW in 2.0.0)**
+- Select tool with modes: Rectangle, Free-form, Lasso (drag required; no click-to-rect)
+- Copy / Cut / Paste for selected annotations
+- Selected items are highlighted after selection; the big selection frame only appears while dragging
+- Delete selected items with **Delete** or **Backspace** (any tool)
+
+🧭 **Transform / Rotate (NEW in 2.0.0)**
+- Transform: scale selected items by dragging a corner handle
+- Rotate: rotate selected items by dragging a corner handle
+- Corner hit-area is enlarged and handles are visible for easier grabbing
+
+📝 **Text Layers (NEW in 2.0.0)**
+- Create resizable text boxes directly on the workflow (drag to size, then type)
+- Choose font family and font size from the context menu controls
+- Double-click a text box to edit it later
+- Text boxes behave like a single object for Select / Transform / Rotate (whole box, not individual letters)
+
+📸 **Screenshot Post-it (NEW in 2.0.0)**
+- Screenshot tool: drag a rectangle to capture any visible area (nodes/images/annotations)
+- The capture becomes a movable “post-it” you can place anywhere
+- Post-it appearance controls: configurable frame color, padding, border width, and optional shadow
+- Move post-its by dragging any edge (works even when Annotate is OFF)
+- Pin a post-it to lock it in place
+
📌 **State Management**
-- Save annotations with workflow (automatic)
+- Local autosave (survives refresh even if you don’t save the workflow)
+- Optional “Save into Workflow” (embeds notes into the workflow JSON)
- Load annotations when reopening workflows
- Quick toggle to hide/show all annotations
- Persistent layer structure across sessions
🖱️ **User Interface**
- Floating button with context menu (right-click)
+- Tools and Post-it settings are also available in the sidebar panel when present; otherwise a draggable dock panel appears near the canvas

@@ -52,8 +83,22 @@ A powerful drawing and annotation tool for ComfyUI that lets you add notes, sket
| **Alt+A** | Toggle annotation mode ON/OFF |
| **Alt+D** | Toggle eraser / draw mode |
| **Alt+S** | Toggle hide/show annotations |
+| **Alt+1** | Tool: Draw |
+| **Alt+2** | Tool: Select |
+| **Alt+3** | Tool: Transform |
+| **Alt+4** | Tool: Rotate |
+| **Alt+5** | Tool: Screenshot |
+| **Alt+P** | Toggle Pin/unpin mode |
+| **Ctrl+I** | Import workflow + notes (JSON) |
+| **Ctrl+C** | Copy selection (Select tool) |
+| **Ctrl+X** | Cut selection (Select tool) |
+| **Ctrl+V** | Paste selection (Select tool) |
+| **Delete / Backspace** | Delete current selection (any tool) |
| **Esc** | Cancel current stroke |
+Notes:
+- Tool shortcuts (**Alt+1..5**) and **Alt+P** are active when Annotate is ON.
+
## Installation
1. Download or clone this extension into your ComfyUI custom_nodes folder:
@@ -93,14 +138,25 @@ git clone https://github.com/IAMCCS/IAMCCS_annotate.git
5. Use **lock icon** to prevent editing
6. Use **X button** to delete layer
+Layer color preview:
+- Each layer stores its own color. When you select a layer, the color picker preview updates immediately to that layer’s saved color.
+
+### Adding Text
+1. In the context menu, click **"+ Text"** (next to **"+ Layer"**)
+2. Drag a rectangle on the workflow to set the text box size
+3. Type your text (Enter confirms, Shift+Enter adds a new line)
+4. Double-click a text box to edit it later
+5. Use **Select / Transform / Rotate** to move/scale/rotate the entire text box
+
### Hiding Annotations
- Press **Alt+S** to quickly toggle visibility of all annotations
- Useful for viewing your workflow without the annotations
### Workflow Integration
-- Annotations are **automatically saved** when you save your workflow
-- When you open a workflow that has annotations, they load automatically
-- Each workflow has its own separate annotations
+- The plugin keeps your changes immediately via **local autosave** (so deletes/edits survive a refresh).
+- If you want notes to travel with the workflow file, enable **Save into Workflow** and then save the workflow.
+- When you open a workflow that has embedded annotations, they load automatically.
+- Each workflow/subgraph keeps its own separate annotations.
## Context Menu Options
@@ -115,6 +171,18 @@ Right-click the floating button to access:
- Dashed lines option
- Hide notes option
- **Layer Management** - Create, rename, manage layers
+- **Text** - Create resizable text boxes (+ Text) with font + size controls
+- **Tools (NEW)**
+ - ✏️ Draw
+ - 🔲 Select (Rectangle / Free-form / Lasso)
+ - 📸 Shot (Screenshot Post-it)
+ - Copy / Cut / Paste
+ - 🔎 Macro
+- **Screenshot Post-it settings (NEW)**
+ - Frame color
+ - Padding
+ - Border width
+ - Shadow toggle
- **Export/Import** - Save/load annotations separately
- **Save into Workflow** - Toggle auto-save feature
@@ -147,8 +215,9 @@ Right-click the floating button to access:
- Verify extension is in `custom_nodes/IAMCCS_annotate`
**Annotations not saving?**
-- Check "Save into workflow" toggle in menu
-- Ensure workflow is saved to disk
+- If you only need persistence after refresh: local autosave should handle it automatically.
+- If you need annotations embedded into the workflow JSON (for sharing/export): enable **Save into Workflow** and save the workflow.
+- Ensure the workflow is saved to disk after enabling the toggle.
- Check browser console for errors
**Eraser not working?**
diff --git a/assets/cover.png b/assets/cover.png
new file mode 100644
index 0000000..7fb0a4d
Binary files /dev/null and b/assets/cover.png differ
diff --git a/assets/cover_square.png b/assets/cover_square.png
new file mode 100644
index 0000000..5d90e76
Binary files /dev/null and b/assets/cover_square.png differ
diff --git a/js/index.js b/js/index.js
index 6915544..8374cd0 100644
--- a/js/index.js
+++ b/js/index.js
@@ -25,6 +25,8 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
paths: [], // {points:[{x,y}], color, width} - kept for backward compatibility
current: null,
saveWithWorkflow: true,
+ // Allow selection/move even when Annotate is OFF
+ selOverride: false,
opacity: 1.0,
eraser: false,
constantScreen: false,
@@ -41,8 +43,74 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
// Layer system
currentLayerIdx: 0,
layers: [{ name: 'Layer 1', visible: true, locked: false, paths: [], style: { color: '#ff4444', dashed: false, widthDraw: 7, widthErase: 48, opacityDraw: 1.0, opacityErase: 1.0 } }],
+
+ // Sticker/post-it objects (Screenshot + Text)
+ stickers: [], // {id, x, y, w, h, rot, kind:'image'|'text', dataUrl?, text?, fontFamily?, fontSize?, textColor?, pinned?}
+ // Sticker appearance (applies to all stickers in the current graph)
+ stickerFrameColor: '#ffffff',
+ stickerPaddingPx: 10,
+ stickerBorderWidthPx: 2,
+ stickerShadow: true,
+ stickerShadowStrength: 12,
+ shotFlash: null, // {t0:number, rect:{x0,y0,x1,y1}}
+
+ // Direct drag of sticker frame while in draw mode
+ stickerDrag: null, // {stickerIdx:number, start:{x,y}, x0:number, y0:number}
+
+ // Resize sticker by corners (Transform tool)
+ stickerResize: null, // {stickerIdx:number, corner:'nw'|'ne'|'se'|'sw', start:{x,y}, x0:number, y0:number, w0:number, h0:number, aspect:number, _historyPushed?:boolean}
+
+ // Resize selected items (Transform tool)
+ transformDrag: null, // {corner:'nw'|'ne'|'se'|'sw', start:{x,y}, bbox0:{x0,y0,x1,y1}, items:{paths:[], stickers:[]}, aspect:number, _historyPushed?:boolean}
+
+ // Rotate selected items (Rotate tool)
+ rotateDrag: null, // {pivot:{x,y}, startAngle:number, angle0:number, items:{paths:[], stickers:[]}, _historyPushed?:boolean}
+
+ // Undo/redo
+ undoStack: [],
+ redoStack: [],
+
+ // Tools
+ tool: 'draw', // 'draw' | 'select' | 'transform' | 'rotate' | 'screenshot' | 'text'
+ selectMode: 'rect', // 'rect' | 'lasso'
+ transformMode: 'fixed', // 'freeform' | 'fixed'
+ selection: null, // {mode, points, rect, selectedPaths, selectedStickers, bbox, dragging...}
+ pendingSelection: null, // {tool, kind:'select', mode, start:{x,y}}; created only after drag threshold
+ clipboard: null, // {paths:[], stickers:[], bbox}
+ lastPointerGraphPos: null,
+
+ // UI timers
+ _paletteHideTimer: null,
+
+ // Pin/unpin stickers
+ pinMode: false,
+
+ // Text tool
+ textFontFamily: 'Arial',
+ textFontSize: 28,
+ textColor: '#111111',
+ textFontWeight: 'normal',
+ textFontStyle: 'normal',
+ textUnderline: false,
+ _textControlsOpen: false,
+ textEditor: null, // { stickerId:string, el:HTMLTextAreaElement }
+ _textEditorBlurTimer: null,
+
+ // Active graph tracking (root vs subgraph)
+ _activeGraphKey: 'root',
+ _switchingGraph: false,
+
+ // Local autosave (survive refresh)
+ _workflowSig: null,
+ _lastAutosaveAt: 0,
+ _autosaveTimer: null,
+ _autosavePending: null,
+
hydrated: false,
uiShown: false,
+
+ // UI watchdog
+ _uiWatchdogId: null,
};
const ui = {
@@ -55,13 +123,60 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
hiddenChk: null, // hide notes checkbox
contextMenu: null,
contextAnchor: null, // { anchor: HTMLElement, dx, dy }
+ toastEl: null,
+ toastTimer: null,
};
+ function showToast(msg, { ms = 1400, kind = 'info' } = {}) {
+ try {
+ if (!msg) return;
+ if (!ui.toastEl || !document.body.contains(ui.toastEl)) {
+ const el = document.createElement('div');
+ el.style.cssText = [
+ 'position:fixed',
+ 'right:16px',
+ 'top:16px',
+ 'z-index:100000',
+ 'max-width:52vw',
+ 'padding:10px 12px',
+ 'border-radius:10px',
+ 'font:12px/1.25 system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif',
+ 'color:#fff',
+ 'box-shadow:0 10px 26px rgba(0,0,0,0.45)',
+ 'border:1px solid rgba(255,255,255,0.10)',
+ 'opacity:0',
+ 'pointer-events:none',
+ 'transition:opacity 160ms ease',
+ ].join(';');
+ document.body.appendChild(el);
+ ui.toastEl = el;
+ }
+ const el = ui.toastEl;
+ const bg = (kind === 'warn') ? 'rgba(180, 60, 40, 0.92)'
+ : (kind === 'ok') ? 'rgba(46, 125, 50, 0.92)'
+ : 'rgba(33, 33, 33, 0.92)';
+ el.style.background = bg;
+ el.textContent = String(msg);
+ el.style.opacity = '0';
+ if (ui.toastTimer) {
+ clearTimeout(ui.toastTimer);
+ ui.toastTimer = null;
+ }
+ requestAnimationFrame(() => { try { el.style.opacity = '1'; } catch {} });
+ ui.toastTimer = setTimeout(() => {
+ try { el.style.opacity = '0'; } catch {}
+ }, Math.max(250, ms | 0));
+ } catch {}
+ }
+
// Offscreen buffer so eraser only affects annotations, not the ComfyUI graph
let __annoCanvas = null;
let __annoCtx = null;
let __annoW = 0, __annoH = 0;
+ // Sticker image cache
+ const __stickerImageCache = new Map(); // key=dataUrl -> HTMLImageElement
+
function loadPos(key, fallback) {
try {
const s = localStorage.getItem(key);
@@ -74,6 +189,1856 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
function savePos(key, pos) {
try { localStorage.setItem(key, JSON.stringify(pos)); } catch {}
}
+
+ // Store large screenshot sticker images outside of localStorage/workflow JSON.
+ // Otherwise ComfyUI's built-in workflow persistence can hit Storage quota and break undo/draw.
+ const __IAMCCS_IDB_NAME = 'iamccs_annotate_db_v1';
+ const __IAMCCS_IDB_STORE_STICKERS = 'sticker_data_v1';
+ const __IAMCCS_IDB_STORE_AUTOSAVE = 'autosave_v1';
+ let __iamccsIdbPromise = null;
+ const __stickerHydratePending = new Set(); // dataKey strings
+
+ function __openIamccsIdb() {
+ try {
+ if (__iamccsIdbPromise) return __iamccsIdbPromise;
+ __iamccsIdbPromise = new Promise((resolve, reject) => {
+ try {
+ const req = indexedDB.open(__IAMCCS_IDB_NAME, 1);
+ req.onupgradeneeded = () => {
+ try {
+ const db = req.result;
+ if (!db.objectStoreNames.contains(__IAMCCS_IDB_STORE_STICKERS)) {
+ db.createObjectStore(__IAMCCS_IDB_STORE_STICKERS);
+ }
+ if (!db.objectStoreNames.contains(__IAMCCS_IDB_STORE_AUTOSAVE)) {
+ db.createObjectStore(__IAMCCS_IDB_STORE_AUTOSAVE);
+ }
+ } catch {}
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ } catch (e) {
+ reject(e);
+ }
+ });
+ return __iamccsIdbPromise;
+ } catch {
+ return Promise.reject(new Error('IndexedDB not available'));
+ }
+ }
+
+ function __idbGet(storeName, key) {
+ return __openIamccsIdb().then((db) => new Promise((resolve) => {
+ try {
+ const tx = db.transaction(storeName, 'readonly');
+ const st = tx.objectStore(storeName);
+ const req = st.get(key);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => resolve(null);
+ } catch {
+ resolve(null);
+ }
+ })).catch(() => null);
+ }
+
+ function __idbSet(storeName, key, value) {
+ return __openIamccsIdb().then((db) => new Promise((resolve) => {
+ try {
+ const tx = db.transaction(storeName, 'readwrite');
+ const st = tx.objectStore(storeName);
+ const req = st.put(value, key);
+ req.onsuccess = () => resolve(true);
+ req.onerror = () => resolve(false);
+ } catch {
+ resolve(false);
+ }
+ })).catch(() => false);
+ }
+
+ function __idbDel(storeName, key) {
+ return __openIamccsIdb().then((db) => new Promise((resolve) => {
+ try {
+ const tx = db.transaction(storeName, 'readwrite');
+ const st = tx.objectStore(storeName);
+ const req = st.delete(key);
+ req.onsuccess = () => resolve(true);
+ req.onerror = () => resolve(false);
+ } catch {
+ resolve(false);
+ }
+ })).catch(() => false);
+ }
+
+ function __idbGetAllKeys(storeName) {
+ return __openIamccsIdb().then((db) => new Promise((resolve) => {
+ try {
+ const tx = db.transaction(storeName, 'readonly');
+ const st = tx.objectStore(storeName);
+
+ if (typeof st.getAllKeys === 'function') {
+ const req = st.getAllKeys();
+ req.onsuccess = () => resolve(Array.isArray(req.result) ? req.result : []);
+ req.onerror = () => resolve([]);
+ return;
+ }
+
+ const out = [];
+ const cur = st.openKeyCursor();
+ cur.onsuccess = () => {
+ try {
+ const c = cur.result;
+ if (!c) return resolve(out);
+ out.push(c.key);
+ c.continue();
+ } catch {
+ resolve(out);
+ }
+ };
+ cur.onerror = () => resolve(out);
+ } catch {
+ resolve([]);
+ }
+ })).catch(() => []);
+ }
+
+ async function purgeOldStickerScreenshotCacheForCurrentWorkflow({ skipConfirm = false } = {}) {
+ try {
+ const ns = ensureWorkflowBlobNamespace();
+ const gk = getActiveGraphKey();
+ const prefix = `sticker:${String(ns)}:${String(gk)}:`;
+
+ const used = new Set();
+ try {
+ for (const st of (state.stickers || [])) {
+ if (!st || isTextSticker(st)) continue;
+ if (typeof st.dataKey === 'string' && st.dataKey.startsWith(prefix)) used.add(st.dataKey);
+ }
+ } catch {}
+
+ const allKeys = await __idbGetAllKeys(__IAMCCS_IDB_STORE_STICKERS);
+ const candidates = (allKeys || []).filter((k) => typeof k === 'string' && k.startsWith(prefix) && !used.has(k));
+
+ if (!candidates.length) {
+ showToast('No old screenshots to purge', { kind: 'ok', ms: 1600 });
+ return { deleted: 0, candidates: 0 };
+ }
+
+ if (!skipConfirm) {
+ const ok = confirm(`Purge ${candidates.length} old cached screenshot(s) for this workflow?\n\nThis only removes deleted/unused screenshots from the browser cache (IndexedDB).`);
+ if (!ok) return { deleted: 0, candidates: candidates.length, canceled: true };
+ }
+
+ let deleted = 0;
+ const chunkSize = 25;
+ for (let i = 0; i < candidates.length; i += chunkSize) {
+ const chunk = candidates.slice(i, i + chunkSize);
+ const res = await Promise.all(chunk.map((k) => __idbDel(__IAMCCS_IDB_STORE_STICKERS, k)));
+ deleted += res.filter(Boolean).length;
+ }
+
+ showToast(`Purged ${deleted} old screenshot(s)`, { kind: 'ok', ms: 1800 });
+ return { deleted, candidates: candidates.length };
+ } catch (e) {
+ console.warn('[IAMCCS] purgeOldStickerScreenshotCacheForCurrentWorkflow failed:', e);
+ showToast('Purge failed (see console)', { kind: 'warn', ms: 2200 });
+ return { deleted: 0, candidates: 0, error: true };
+ }
+ }
+
+ function ensureWorkflowBlobNamespace() {
+ try {
+ if (state.saveWithWorkflow && app?.graph) {
+ app.graph.extra = app.graph.extra || {};
+ const k = 'iamccs_annotate_blob_ns';
+ if (!app.graph.extra[k]) app.graph.extra[k] = `blns_${Date.now()}_${Math.random().toString(16).slice(2)}`;
+ return String(app.graph.extra[k]);
+ }
+ } catch {}
+ try { if (!state._workflowSig) state._workflowSig = computeWorkflowSignature(); } catch {}
+ return String(state._workflowSig || 'unknown');
+ }
+
+ function stickerDataKeyForId(stickerId) {
+ try {
+ const ns = ensureWorkflowBlobNamespace();
+ const gk = getActiveGraphKey();
+ return `sticker:${ns}:${gk}:${String(stickerId || '')}`;
+ } catch {
+ return null;
+ }
+ }
+
+ function requestHydrateStickerData(stickerId, dataKey) {
+ try {
+ if (!stickerId || !dataKey) return;
+ const k = String(dataKey);
+ if (__stickerHydratePending.has(k)) return;
+ __stickerHydratePending.add(k);
+ __idbGet(__IAMCCS_IDB_STORE_STICKERS, k).then((val) => {
+ try {
+ __stickerHydratePending.delete(k);
+ if (typeof val !== 'string' || !val) return;
+ const idx = getStickerIdxById(stickerId);
+ const st = idx >= 0 ? state.stickers?.[idx] : null;
+ if (!st) return;
+ if (st.dataKey !== k) return;
+ if (st.dataUrl) return;
+ st.dataUrl = val;
+ try { app?.canvas?.setDirty(true, true); } catch {}
+ } catch {}
+ });
+ } catch {}
+ }
+
+ const __IAMCCS_AUTOSAVE_NS = 'iamccs_annotations_autosave_v2';
+
+ // Legacy migration: older versions stored autosave payloads in localStorage.
+ // Large payloads (or many workflows) can fill quota and break ComfyUI workflow persistence.
+ // We now store autosave in IndexedDB and migrate legacy keys to free quota WITHOUT data loss.
+ async function migrateLegacyAutosaveLocalStorageToIdb({ removeAfter = true } = {}) {
+ try {
+ const prefix = `${__IAMCCS_AUTOSAVE_NS}:`;
+ const keys = [];
+ for (let i = 0; i < (localStorage?.length || 0); i++) {
+ const k = localStorage.key(i);
+ if (k && typeof k === 'string' && k.startsWith(prefix)) keys.push(k);
+ }
+ if (!keys.length) return { found: 0, migrated: 0, removed: 0 };
+
+ let migrated = 0;
+ let removed = 0;
+ for (const k of keys) {
+ try {
+ const s = localStorage.getItem(k);
+ if (!s) {
+ if (removeAfter) {
+ try { localStorage.removeItem(k); removed++; } catch {}
+ }
+ continue;
+ }
+ let v = null;
+ try { v = JSON.parse(s); } catch { v = null; }
+ if (v && typeof v === 'object') {
+ const ok = await __idbSet(__IAMCCS_IDB_STORE_AUTOSAVE, k, v);
+ if (ok) migrated++;
+ if (ok && removeAfter) {
+ try { localStorage.removeItem(k); removed++; } catch {}
+ }
+ }
+ } catch {}
+ }
+ console.log(`[IAMCCS] Migrated legacy autosaves: found=${keys.length}, migrated=${migrated}, removed=${removed}`);
+ return { found: keys.length, migrated, removed };
+ } catch {
+ return { found: 0, migrated: 0, removed: 0, error: true };
+ }
+ }
+
+ function djb2Hash(str) {
+ let h = 5381;
+ for (let i = 0; i < str.length; i++) h = ((h << 5) + h) ^ str.charCodeAt(i);
+ return (h >>> 0).toString(16);
+ }
+
+ function computeWorkflowSignature() {
+ try {
+ const g = app?.graph;
+ if (!g || typeof g.serialize !== 'function') return 'unknown';
+ const wf0 = g.serialize();
+ if (!wf0 || typeof wf0 !== 'object') return 'unknown';
+
+ // Work on a copy so we never mutate the serialized object (some implementations
+ // may share references like `extra` with the live graph).
+ const wf = (() => {
+ try {
+ const extra0 = (wf0.extra && typeof wf0.extra === 'object') ? wf0.extra : null;
+ const extra = extra0 ? { ...extra0 } : undefined;
+ return { ...wf0, ...(extra ? { extra } : {}) };
+ } catch {
+ return wf0;
+ }
+ })();
+
+ // Remove our own payload so signature doesn't change when annotations change
+ try {
+ if (wf.extra && typeof wf.extra === 'object') {
+ delete wf.extra.iamccs_annotations;
+ delete wf.extra.iamccs_annotations_multi;
+ }
+ } catch {}
+
+ // IMPORTANT:
+ // Previous versions hashed only (node types + links). Two different workflows with the same
+ // topology could collide and reuse the same local autosave, leaking layers/options across workflows.
+ // Hash the full workflow serialization (minus our extra) to make the signature truly per-workflow.
+ let base = '';
+ try {
+ base = JSON.stringify(wf);
+ } catch {
+ // Fallback if stringify fails for any reason
+ const nodes = Array.isArray(wf.nodes) ? wf.nodes.map(n => `${n?.id}:${n?.type}`) : [];
+ nodes.sort();
+ const links = Array.isArray(wf.links) ? wf.links.map(l => Array.isArray(l) ? l.join(':') : String(l)) : [];
+ links.sort();
+ base = nodes.join('|') + '||' + links.join('|');
+ }
+ return djb2Hash(base);
+ } catch {
+ return 'unknown';
+ }
+ }
+
+ function getAutosaveKey(graphKey) {
+ const sig = state._workflowSig || (state._workflowSig = computeWorkflowSignature());
+ const gk = String(graphKey || getActiveGraphKey() || 'root');
+ return `${__IAMCCS_AUTOSAVE_NS}:${sig}:${gk}`;
+ }
+
+ function flushLocalAutosave() {
+ try {
+ const pending = state._autosavePending;
+ if (!pending) return;
+ state._autosavePending = null;
+ state._lastAutosaveAt = Date.now();
+ const key = getAutosaveKey(state._activeGraphKey);
+ // Store autosave in IndexedDB (not localStorage) to avoid impacting ComfyUI persistence.
+ try { __idbSet(__IAMCCS_IDB_STORE_AUTOSAVE, key, { savedAt: state._lastAutosaveAt, payload: pending }); } catch {}
+ } catch {}
+ }
+
+ function scheduleLocalAutosave(payload, { immediate = false } = {}) {
+ try {
+ state._autosavePending = payload;
+ const now = Date.now();
+ if (immediate || (now - (state._lastAutosaveAt || 0) > 250)) {
+ flushLocalAutosave();
+ return;
+ }
+ if (state._autosaveTimer) return;
+ state._autosaveTimer = setTimeout(() => {
+ state._autosaveTimer = null;
+ flushLocalAutosave();
+ }, 280);
+ } catch {}
+ }
+
+ function readLocalAutosaveLegacy(graphKey) {
+ try {
+ const key = getAutosaveKey(graphKey);
+ const s = localStorage.getItem(key);
+ if (!s) return null;
+ const v = JSON.parse(s);
+ if (v && typeof v === 'object' && v.payload && typeof v.payload === 'object') return v.payload;
+ } catch {}
+ return null;
+ }
+
+ async function readLocalAutosaveAsync(graphKey) {
+ try {
+ const key = getAutosaveKey(graphKey);
+ const v = await __idbGet(__IAMCCS_IDB_STORE_AUTOSAVE, key);
+ if (v && typeof v === 'object' && v.payload && typeof v.payload === 'object') return v.payload;
+ } catch {}
+ // Fallback for older installations that still have localStorage autosaves.
+ return readLocalAutosaveLegacy(graphKey);
+ }
+
+ function normalizeRect(r) {
+ if (!r) return null;
+ const x0 = Math.min(r.x0, r.x1);
+ const y0 = Math.min(r.y0, r.y1);
+ const x1 = Math.max(r.x0, r.x1);
+ const y1 = Math.max(r.y0, r.y1);
+ return { x0, y0, x1, y1, w: x1 - x0, h: y1 - y0 };
+ }
+
+ function bboxUnion(a, b) {
+ if (!a) return b;
+ if (!b) return a;
+ return { x0: Math.min(a.x0, b.x0), y0: Math.min(a.y0, b.y0), x1: Math.max(a.x1, b.x1), y1: Math.max(a.y1, b.y1) };
+ }
+
+ function pointInRect(p, r) {
+ if (!p || !r) return false;
+ return p.x >= r.x0 && p.x <= r.x1 && p.y >= r.y0 && p.y <= r.y1;
+ }
+
+ function pathBBox(path) {
+ if (!path || !Array.isArray(path.points) || !path.points.length) return null;
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+ for (const pt of path.points) {
+ if (!pt) continue;
+ if (pt.x < x0) x0 = pt.x;
+ if (pt.y < y0) y0 = pt.y;
+ if (pt.x > x1) x1 = pt.x;
+ if (pt.y > y1) y1 = pt.y;
+ }
+ if (!isFinite(x0)) return null;
+ return { x0, y0, x1, y1 };
+ }
+
+ function roundedRectPath(ctx, x, y, w, h, r) {
+ const rr = Math.max(0, Math.min(r || 0, Math.min(w, h) / 2));
+ ctx.beginPath();
+ if (rr <= 0.001) {
+ ctx.rect(x, y, w, h);
+ return;
+ }
+ ctx.moveTo(x + rr, y);
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
+ ctx.arcTo(x, y + h, x, y, rr);
+ ctx.arcTo(x, y, x + w, y, rr);
+ ctx.closePath();
+ }
+
+ function rectIntersects(a, b) {
+ if (!a || !b) return false;
+ return !(a.x1 < b.x0 || a.x0 > b.x1 || a.y1 < b.y0 || a.y0 > b.y1);
+ }
+
+ function rectCenter(r) {
+ if (!r) return null;
+ return { x: (r.x0 + r.x1) * 0.5, y: (r.y0 + r.y1) * 0.5 };
+ }
+
+ function pointInPolygon(pt, poly) {
+ if (!pt || !Array.isArray(poly) || poly.length < 3) return false;
+ // Ray casting
+ let inside = false;
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
+ const xi = poly[i].x, yi = poly[i].y;
+ const xj = poly[j].x, yj = poly[j].y;
+ const intersect = ((yi > pt.y) !== (yj > pt.y)) &&
+ (pt.x < ((xj - xi) * (pt.y - yi)) / ((yj - yi) || 1e-9) + xi);
+ if (intersect) inside = !inside;
+ }
+ return inside;
+ }
+
+ function clonePath(path) {
+ return {
+ color: path.color,
+ width: path.width,
+ opacity: path.opacity,
+ mode: path.mode,
+ dashed: !!path.dashed,
+ parentStickerId: (typeof path.parentStickerId === 'string' && path.parentStickerId) ? path.parentStickerId : undefined,
+ points: Array.isArray(path.points) ? path.points.map(p => ({ x: p.x, y: p.y })) : [],
+ };
+ }
+
+ function cloneSticker(st) {
+ return {
+ id: st.id,
+ x: st.x,
+ y: st.y,
+ w: st.w,
+ h: st.h,
+ rot: typeof st.rot === 'number' ? st.rot : 0,
+ pinned: !!st.pinned,
+ parentStickerId: (typeof st.parentStickerId === 'string' && st.parentStickerId) ? st.parentStickerId : undefined,
+ kind: (st.kind === 'text') ? 'text'
+ : (st.kind === 'image') ? 'image'
+ : ((typeof st.text === 'string' && !st.dataUrl && !st.dataKey) ? 'text' : 'image'),
+ dataUrl: st.dataUrl,
+ dataKey: (typeof st.dataKey === 'string') ? st.dataKey : undefined,
+ text: (typeof st.text === 'string') ? st.text : '',
+ fontFamily: (typeof st.fontFamily === 'string') ? st.fontFamily : undefined,
+ fontSize: (typeof st.fontSize === 'number') ? st.fontSize : undefined,
+ textColor: (typeof st.textColor === 'string') ? st.textColor : undefined,
+ fontWeight: (typeof st.fontWeight === 'string') ? st.fontWeight : undefined,
+ fontStyle: (typeof st.fontStyle === 'string') ? st.fontStyle : undefined,
+ underline: !!st.underline,
+ };
+ }
+
+ const __HISTORY_LIMIT = 60;
+ function __deepCloneJson(obj) {
+ return JSON.parse(JSON.stringify(obj));
+ }
+
+ function buildHistorySnapshot() {
+ return {
+ layers: state.layers,
+ currentLayerIdx: state.currentLayerIdx,
+ stickers: state.stickers,
+ };
+ }
+
+ function applyHistorySnapshot(snap) {
+ if (!snap || typeof snap !== 'object') return false;
+ try {
+ state.current = null;
+ state.selection = null;
+ state.stickerDrag = null;
+
+ state.layers = Array.isArray(snap.layers) ? __deepCloneJson(snap.layers) : state.layers;
+ state.currentLayerIdx = (typeof snap.currentLayerIdx === 'number') ? snap.currentLayerIdx : 0;
+ state.stickers = Array.isArray(snap.stickers) ? __deepCloneJson(snap.stickers) : [];
+
+ // Refresh flat paths for backward compatibility
+ state.paths = [];
+ for (const lyr of state.layers) for (const p of (lyr.paths || [])) state.paths.push(p);
+
+ // Clamp layer index
+ if (state.currentLayerIdx >= state.layers.length) state.currentLayerIdx = Math.max(0, state.layers.length - 1);
+
+ try { applyLayerStyleToState(); } catch {}
+ try { syncUI(); } catch {}
+ try { syncBrushOpacityUI(); } catch {}
+ try { syncToolsUI(); } catch {}
+ try { syncStickerUI(); } catch {}
+
+ app?.canvas?.setDirty(true, true);
+ if (state.saveWithWorkflow) persistToGraphExtra(true);
+ return true;
+ } catch (e) {
+ console.warn('[IAMCCS] applyHistorySnapshot failed:', e);
+ return false;
+ }
+ }
+
+ function pushHistorySnapshot() {
+ try {
+ if (state._switchingGraph) return;
+ if (state._historyApplying) return;
+ const snap = __deepCloneJson(buildHistorySnapshot());
+ state.undoStack = state.undoStack || [];
+ state.redoStack = state.redoStack || [];
+ state.undoStack.push(snap);
+ if (state.undoStack.length > __HISTORY_LIMIT) state.undoStack.shift();
+ state.redoStack.length = 0;
+ } catch {}
+ }
+
+ function doUndo() {
+ try {
+ const undo = state.undoStack || [];
+ if (!undo.length) return false;
+ const current = __deepCloneJson(buildHistorySnapshot());
+ const prev = undo.pop();
+ state.redoStack = state.redoStack || [];
+ state.redoStack.push(current);
+ state._historyApplying = true;
+ const ok = applyHistorySnapshot(prev);
+ state._historyApplying = false;
+ return ok;
+ } catch {
+ state._historyApplying = false;
+ return false;
+ }
+ }
+
+ function doRedo() {
+ try {
+ const redo = state.redoStack || [];
+ if (!redo.length) return false;
+ const current = __deepCloneJson(buildHistorySnapshot());
+ const next = redo.pop();
+ state.undoStack = state.undoStack || [];
+ state.undoStack.push(current);
+ if (state.undoStack.length > __HISTORY_LIMIT) state.undoStack.shift();
+ state._historyApplying = true;
+ const ok = applyHistorySnapshot(next);
+ state._historyApplying = false;
+ return ok;
+ } catch {
+ state._historyApplying = false;
+ return false;
+ }
+ }
+
+ function startUIWatchdog() {
+ try {
+ if (state._uiWatchdogId) return;
+ state._uiWatchdogId = setInterval(() => {
+ try {
+ if (!ui.floating || !document.body.contains(ui.floating)) {
+ ensureFloatingToggle();
+ state.uiShown = true;
+ }
+ } catch {}
+ }, 1500);
+ } catch {}
+ }
+
+ function hitTestStickerBorder(graphPos, canvas, borderCssPx = 7) {
+ if (!graphPos || !canvas || !Array.isArray(state.stickers) || !state.stickers.length) return -1;
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ const thrG = Math.max(0.5, borderCssPx / sc);
+ for (let i = state.stickers.length - 1; i >= 0; i--) {
+ const st = state.stickers[i];
+ if (!st) continue;
+ if (!isTextStickerVisible(st)) continue;
+ const w = typeof st.w === 'number' ? st.w : 0;
+ const h = typeof st.h === 'number' ? st.h : 0;
+ if (w <= 1 || h <= 1) continue;
+ const rot = (typeof st.rot === 'number') ? st.rot : 0;
+ const cx = (typeof st.x === 'number' ? st.x : 0) + w / 2;
+ const cy = (typeof st.y === 'number' ? st.y : 0) + h / 2;
+
+ const dx = graphPos.x - cx;
+ const dy = graphPos.y - cy;
+ const c = Math.cos(rot);
+ const s = Math.sin(rot);
+ // rotate by -rot into local sticker space
+ const lx = dx * c + dy * s;
+ const ly = -dx * s + dy * c;
+
+ const hw = w / 2;
+ const hh = h / 2;
+ if (Math.abs(lx) > hw || Math.abs(ly) > hh) continue;
+ const distToEdge = Math.min(hw - Math.abs(lx), hh - Math.abs(ly));
+ if (distToEdge <= thrG) return i;
+ }
+ return -1;
+ }
+
+ function movementThresholdGraph(canvas, cssPx = 6) {
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ return Math.max(1e-3, cssPx / sc);
+ }
+
+ function schedulePaletteAutoHide(sourceEl) {
+ try {
+ if (state._paletteHideTimer) clearTimeout(state._paletteHideTimer);
+ state._paletteHideTimer = setTimeout(() => {
+ // Auto-hide should close only the native color picker (palette), not the whole options panel.
+ // Blurring the is enough; do NOT close context menu / options UI.
+ try {
+ if (sourceEl && typeof sourceEl.blur === 'function') sourceEl.blur();
+ } catch {}
+ }, 5000);
+ } catch {}
+ }
+
+ function hitTestStickerCorner(graphPos, canvas, cornerCssPx = 10) {
+ if (!graphPos || !canvas || !Array.isArray(state.stickers) || !state.stickers.length) return null;
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ const thrG = Math.max(0.5, cornerCssPx / sc);
+ for (let i = state.stickers.length - 1; i >= 0; i--) {
+ const st = state.stickers[i];
+ if (!st) continue;
+ if (!isTextStickerVisible(st)) continue;
+ const w = typeof st.w === 'number' ? st.w : 0;
+ const h = typeof st.h === 'number' ? st.h : 0;
+ if (w <= 1 || h <= 1) continue;
+ const rot = (typeof st.rot === 'number') ? st.rot : 0;
+ const cx = (typeof st.x === 'number' ? st.x : 0) + w / 2;
+ const cy = (typeof st.y === 'number' ? st.y : 0) + h / 2;
+ const c = Math.cos(rot);
+ const s = Math.sin(rot);
+
+ const localCorners = [
+ { c: 'nw', x: -w / 2, y: -h / 2 },
+ { c: 'ne', x: w / 2, y: -h / 2 },
+ { c: 'se', x: w / 2, y: h / 2 },
+ { c: 'sw', x: -w / 2, y: h / 2 },
+ ];
+ for (const k of localCorners) {
+ const gx = cx + (k.x * c - k.y * s);
+ const gy = cy + (k.x * s + k.y * c);
+ if (Math.abs(graphPos.x - gx) <= thrG && Math.abs(graphPos.y - gy) <= thrG) return { stickerIdx: i, corner: k.c };
+ }
+ }
+ return null;
+ }
+
+ function hitTestStickerInside(graphPos, canvas) {
+ if (!graphPos || !canvas || !Array.isArray(state.stickers) || !state.stickers.length) return -1;
+ for (let i = state.stickers.length - 1; i >= 0; i--) {
+ const st = state.stickers[i];
+ if (!st) continue;
+ if (!isTextStickerVisible(st)) continue;
+ const w = typeof st.w === 'number' ? st.w : 0;
+ const h = typeof st.h === 'number' ? st.h : 0;
+ if (w <= 1 || h <= 1) continue;
+ const rot = (typeof st.rot === 'number') ? st.rot : 0;
+ const cx = (typeof st.x === 'number' ? st.x : 0) + w / 2;
+ const cy = (typeof st.y === 'number' ? st.y : 0) + h / 2;
+
+ const dx = graphPos.x - cx;
+ const dy = graphPos.y - cy;
+ const c = Math.cos(rot);
+ const s = Math.sin(rot);
+ const lx = dx * c + dy * s;
+ const ly = -dx * s + dy * c;
+ const hw = w / 2;
+ const hh = h / 2;
+ if (Math.abs(lx) <= hw && Math.abs(ly) <= hh) return i;
+ }
+ return -1;
+ }
+
+ function isImageSticker(st) {
+ if (!st) return false;
+ if (st.kind === 'image') return true;
+ if (st.kind === 'text') return false;
+ return (!!st.dataUrl || !!st.dataKey) && !isTextSticker(st);
+ }
+
+ function hitTestImageStickerInside(graphPos, canvas) {
+ if (!graphPos || !canvas || !Array.isArray(state.stickers) || !state.stickers.length) return -1;
+ for (let i = state.stickers.length - 1; i >= 0; i--) {
+ const st = state.stickers[i];
+ if (!st) continue;
+ if (!isImageSticker(st)) continue;
+ if (!isTextStickerVisible(st)) continue;
+ const w = typeof st.w === 'number' ? st.w : 0;
+ const h = typeof st.h === 'number' ? st.h : 0;
+ if (w <= 1 || h <= 1) continue;
+ const rot = (typeof st.rot === 'number') ? st.rot : 0;
+ const cx = (typeof st.x === 'number' ? st.x : 0) + w / 2;
+ const cy = (typeof st.y === 'number' ? st.y : 0) + h / 2;
+
+ const dx = graphPos.x - cx;
+ const dy = graphPos.y - cy;
+ const c = Math.cos(rot);
+ const s = Math.sin(rot);
+ const lx = dx * c + dy * s;
+ const ly = -dx * s + dy * c;
+ const hw = w / 2;
+ const hh = h / 2;
+ if (Math.abs(lx) <= hw && Math.abs(ly) <= hh) return i;
+ }
+ return -1;
+ }
+
+ function collectAnchoredChildrenSnapshot(parentStickerId, { skipStickerIds, skipPathKeys } = {}) {
+ const snap = { paths: [], stickers: [] };
+ if (!parentStickerId) return snap;
+
+ const skipSt = skipStickerIds || null;
+ const skipPk = skipPathKeys || null;
+
+ // Paths anchored to parentStickerId
+ for (let li = 0; li < (state.layers?.length || 0); li++) {
+ const layer = state.layers[li];
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ for (let pi = 0; pi < layer.paths.length; pi++) {
+ const p = layer.paths[pi];
+ if (!p) continue;
+ if (p.parentStickerId !== parentStickerId) continue;
+ const key = `${li}:${pi}`;
+ if (skipPk && skipPk.has(key)) continue;
+ snap.paths.push({
+ layerIdx: li,
+ pathIdx: pi,
+ points0: Array.isArray(p.points) ? p.points.map(pt => ({ x: pt.x, y: pt.y })) : [],
+ });
+ }
+ }
+
+ // Stickers anchored to parentStickerId
+ for (let si = 0; si < (state.stickers?.length || 0); si++) {
+ const st = state.stickers[si];
+ if (!st) continue;
+ if (st.id === parentStickerId) continue;
+ if (st.parentStickerId !== parentStickerId) continue;
+ if (skipSt && st.id && skipSt.has(st.id)) continue;
+ snap.stickers.push({
+ stickerIdx: si,
+ x0: st.x,
+ y0: st.y,
+ w0: st.w,
+ h0: st.h,
+ rot0: (typeof st.rot === 'number') ? st.rot : 0,
+ });
+ }
+
+ return snap;
+ }
+
+ function applyAnchoredSnapshotDelta(snap, dx, dy) {
+ if (!snap) return;
+
+ for (const it of (snap.paths || [])) {
+ const layer = state.layers?.[it.layerIdx];
+ const p = layer?.paths?.[it.pathIdx];
+ if (!p || !Array.isArray(it.points0)) continue;
+ p.points = it.points0.map(pt => ({ x: pt.x + dx, y: pt.y + dy }));
+ }
+
+ for (const it of (snap.stickers || [])) {
+ const st = state.stickers?.[it.stickerIdx];
+ if (!st) continue;
+ st.x = it.x0 + dx;
+ st.y = it.y0 + dy;
+ }
+ }
+
+ function applyAnchoredSnapshotStickerResize(snap, parent0, parent1) {
+ if (!snap || !parent0 || !parent1) return;
+ const w0 = Number(parent0.w) || 0;
+ const h0 = Number(parent0.h) || 0;
+ const w1 = Number(parent1.w) || 0;
+ const h1 = Number(parent1.h) || 0;
+ if (w0 <= 1e-6 || h0 <= 1e-6 || w1 <= 1e-6 || h1 <= 1e-6) return;
+
+ const rot = (typeof parent0.rot === 'number') ? parent0.rot : ((typeof parent1.rot === 'number') ? parent1.rot : 0);
+ const c0 = { x: (Number(parent0.x) || 0) + w0 / 2, y: (Number(parent0.y) || 0) + h0 / 2 };
+ const c1 = { x: (Number(parent1.x) || 0) + w1 / 2, y: (Number(parent1.y) || 0) + h1 / 2 };
+ const sx = w1 / w0;
+ const sy = h1 / h0;
+ const cr = Math.cos(rot);
+ const sr = Math.sin(rot);
+
+ const mapPoint = (pt0) => {
+ const dx = pt0.x - c0.x;
+ const dy = pt0.y - c0.y;
+ // local coords (unrotate)
+ const lx0 = dx * cr + dy * sr;
+ const ly0 = -dx * sr + dy * cr;
+ // scale in local coords
+ const lx1 = lx0 * sx;
+ const ly1 = ly0 * sy;
+ // rotate back
+ const wx = lx1 * cr - ly1 * sr;
+ const wy = lx1 * sr + ly1 * cr;
+ return { x: c1.x + wx, y: c1.y + wy };
+ };
+
+ // Paths
+ for (const it of (snap.paths || [])) {
+ const layer = state.layers?.[it.layerIdx];
+ const p = layer?.paths?.[it.pathIdx];
+ if (!p || !Array.isArray(it.points0)) continue;
+ p.points = it.points0.map(mapPoint);
+ }
+
+ // Stickers
+ for (const it of (snap.stickers || [])) {
+ const st = state.stickers?.[it.stickerIdx];
+ if (!st) continue;
+ const sw0 = Number(it.w0) || Number(st.w) || 0;
+ const sh0 = Number(it.h0) || Number(st.h) || 0;
+ const cc0 = { x: (Number(it.x0) || 0) + sw0 / 2, y: (Number(it.y0) || 0) + sh0 / 2 };
+ const cc1 = mapPoint(cc0);
+ const sw1 = Math.max(5, sw0 * sx);
+ const sh1 = Math.max(5, sh0 * sy);
+ st.w = sw1;
+ st.h = sh1;
+ st.x = cc1.x - sw1 / 2;
+ st.y = cc1.y - sh1 / 2;
+ // No rotation delta during resize; keep original rotation
+ if (typeof it.rot0 === 'number') st.rot = it.rot0;
+ }
+ }
+
+ function hitTestBBoxCorner(graphPos, bbox, canvas, cornerCssPx = 10) {
+ if (!graphPos || !bbox || !canvas) return null;
+ const dpr = getCanvasEffectiveDpr(canvas);
+ const thr = Math.max(1, cornerCssPx * dpr);
+ const p = graphToCanvasPx(graphPos, canvas);
+ const r = graphRectToCanvasPxRect(bbox, canvas);
+ if (!r) return null;
+ const corners = [
+ { c: 'nw', x: r.x, y: r.y },
+ { c: 'ne', x: r.x + r.w, y: r.y },
+ { c: 'se', x: r.x + r.w, y: r.y + r.h },
+ { c: 'sw', x: r.x, y: r.y + r.h },
+ ];
+ for (const k of corners) {
+ if (Math.abs(p.x - k.x) <= thr && Math.abs(p.y - k.y) <= thr) return k.c;
+ }
+ return null;
+ }
+
+ function getCanvasEffectiveDpr(canvas) {
+ try {
+ const el = canvas?.canvas || canvas;
+ if (!el || typeof el.getBoundingClientRect !== 'function') return (window.devicePixelRatio || 1);
+ const r = el.getBoundingClientRect();
+ const cssW = Number(r?.width) || 0;
+ const cssH = Number(r?.height) || 0;
+ const pxW = Number(el.width) || 0;
+ const pxH = Number(el.height) || 0;
+ if (cssW > 0 && pxW > 0) return pxW / cssW;
+ if (cssH > 0 && pxH > 0) return pxH / cssH;
+ } catch {}
+ return (window.devicePixelRatio || 1);
+ }
+
+ function graphToCanvasPx(pt, canvas, dprOverride) {
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1, offset: canvas?.offset || [0, 0] };
+ const dpr = (typeof dprOverride === 'number' ? dprOverride : getCanvasEffectiveDpr(canvas));
+ const sc = ds.scale || 1;
+ const off0 = ds.offset?.[0] || 0;
+ const off1 = ds.offset?.[1] || 0;
+ return {
+ // In litegraph: screen = (graph + offset) * scale
+ x: ((pt.x + off0) * sc) * dpr,
+ y: ((pt.y + off1) * sc) * dpr,
+ };
+ }
+
+ function graphRectToCanvasPxRect(rectGraph, canvas) {
+ const r = normalizeRect(rectGraph);
+ if (!r) return null;
+ const p0 = graphToCanvasPx({ x: r.x0, y: r.y0 }, canvas);
+ const p1 = graphToCanvasPx({ x: r.x1, y: r.y1 }, canvas);
+ const x = Math.min(p0.x, p1.x);
+ const y = Math.min(p0.y, p1.y);
+ const w = Math.max(1, Math.abs(p1.x - p0.x));
+ const h = Math.max(1, Math.abs(p1.y - p0.y));
+ return { x, y, w, h };
+ }
+
+ function cropCanvasToDataUrl(canvasEl, rectPx) {
+ if (!canvasEl || !rectPx) return null;
+ const sx = Math.max(0, Math.floor(rectPx.x));
+ const sy = Math.max(0, Math.floor(rectPx.y));
+ const sw = Math.max(1, Math.floor(rectPx.w));
+ const sh = Math.max(1, Math.floor(rectPx.h));
+ const maxW = canvasEl.width | 0;
+ const maxH = canvasEl.height | 0;
+ const cw = Math.max(1, Math.min(sw, maxW - sx));
+ const ch = Math.max(1, Math.min(sh, maxH - sy));
+ if (cw <= 1 || ch <= 1) return null;
+ const tmp = document.createElement('canvas');
+ tmp.width = cw;
+ tmp.height = ch;
+ const tctx = tmp.getContext('2d');
+ tctx.imageSmoothingEnabled = true;
+ tctx.drawImage(canvasEl, sx, sy, cw, ch, 0, 0, cw, ch);
+ return tmp.toDataURL('image/png');
+ }
+
+ function newStickerId() {
+ return `st_${Date.now()}_${Math.random().toString(16).slice(2)}`;
+ }
+
+ function isTextSticker(st) {
+ if (!st) return false;
+ if (st.kind === 'text') return true;
+ // Only infer text when there is no image payload (dataUrl or dataKey)
+ return (typeof st.text === 'string' && !st.dataUrl && !st.dataKey);
+ }
+
+ function findTextLayerIndexByStickerId(stickerId) {
+ if (!stickerId) return -1;
+ for (let i = 0; i < (state.layers?.length || 0); i++) {
+ const lyr = state.layers[i];
+ if (lyr && lyr.kind === 'text' && lyr.textStickerId === stickerId) return i;
+ }
+ return -1;
+ }
+
+ function isTextStickerVisible(st) {
+ if (!st || !isTextSticker(st)) return true;
+ const li = findTextLayerIndexByStickerId(st.id);
+ if (li < 0) return true;
+ const lyr = state.layers?.[li];
+ return lyr?.visible !== false;
+ }
+
+ function nextTextLayerName() {
+ let maxN = 0;
+ for (const lyr of (state.layers || [])) {
+ const name = String(lyr?.name || '');
+ const m = name.match(/^Text Layer\s+(\d+)$/i);
+ if (!m) continue;
+ const n = parseInt(m[1], 10);
+ if (isFinite(n)) maxN = Math.max(maxN, n);
+ }
+ return `Text Layer ${maxN + 1}`;
+ }
+
+ function syncTextLayerFromStateColor(layerIdx) {
+ const li = (typeof layerIdx === 'number') ? layerIdx : state.currentLayerIdx;
+ const lyr = state.layers?.[li];
+ if (!lyr || lyr.kind !== 'text') return false;
+ lyr.style = lyr.style || {};
+ lyr.style.color = state.color;
+ const sid = lyr.textStickerId;
+ if (sid) {
+ const si = getStickerIdxById(sid);
+ const st = si >= 0 ? state.stickers?.[si] : null;
+ if (st && isTextSticker(st)) {
+ st.textColor = state.color;
+ if (state.textEditor?.stickerId === sid && state.textEditor?.el) {
+ state.textEditor.el.style.color = state.color;
+ }
+ }
+ }
+ persistToGraphExtra();
+ app?.canvas?.setDirty(true, true);
+ return true;
+ }
+
+ function getStickerIdxById(stickerId) {
+ if (!stickerId) return -1;
+ const arr = state.stickers || [];
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i] && arr[i].id === stickerId) return i;
+ }
+ return -1;
+ }
+
+ function closeTextEditor({ commit = true } = {}) {
+ let closedStickerIdx = -1;
+ try {
+ try {
+ if (state._textEditorBlurTimer) {
+ clearTimeout(state._textEditorBlurTimer);
+ state._textEditorBlurTimer = null;
+ }
+ } catch {}
+ const te = state.textEditor;
+ if (!te || !te.el) {
+ state.textEditor = null;
+ return;
+ }
+ const el = te.el;
+ const idx = getStickerIdxById(te.stickerId);
+ const st = idx >= 0 ? state.stickers[idx] : null;
+ closedStickerIdx = idx;
+ if (commit && st && isTextSticker(st)) {
+ st.text = String(el.value || '');
+ persistToGraphExtra(true);
+ }
+ try { el.remove(); } catch {}
+ } catch {}
+ state.textEditor = null;
+ // UX: after typing, go back to Select tool
+ try {
+ if (commit && state.tool === 'text') setTool('select');
+ } catch {}
+
+ // UX: after closing editor, select that text sticker immediately
+ try {
+ if (commit && closedStickerIdx >= 0) {
+ const st = state.stickers?.[closedStickerIdx];
+ const bb = stickerBBox(st);
+ if (bb) {
+ state.selection = {
+ kind: 'select',
+ mode: 'rect',
+ points: [],
+ rect: null,
+ bbox: bb,
+ selectedPaths: [],
+ selectedStickers: [{ stickerIdx: closedStickerIdx, bbox: bb }],
+ dragging: false,
+ };
+ }
+ }
+ } catch {}
+ try { app?.canvas?.setDirty(true, true); } catch {}
+ }
+
+ function openTextEditorForStickerId(stickerId, canvas) {
+ // Close any existing editor first
+ closeTextEditor({ commit: true });
+ const idx = getStickerIdxById(stickerId);
+ const st = idx >= 0 ? state.stickers[idx] : null;
+ if (!st || !isTextSticker(st)) return;
+
+ // If linked to a Text Layer, select it so palette changes affect this text
+ try {
+ const li = findTextLayerIndexByStickerId(stickerId);
+ if (li >= 0) setCurrentLayer(li);
+ } catch {}
+
+ // Sync global Text tool settings to this sticker (so UI reflects current style)
+ try {
+ if (typeof st.fontFamily === 'string') state.textFontFamily = st.fontFamily;
+ if (typeof st.fontSize === 'number') state.textFontSize = st.fontSize;
+ state.textFontWeight = String(st.fontWeight || state.textFontWeight || 'normal');
+ state.textFontStyle = String(st.fontStyle || state.textFontStyle || 'normal');
+ state.textUnderline = !!(st.underline ?? state.textUnderline);
+ if (ui.contextMenu) {
+ const tf = ui.contextMenu.querySelector('#ctx_text_font');
+ const ts = ui.contextMenu.querySelector('#ctx_text_size');
+ const tb = ui.contextMenu.querySelector('#ctx_text_bold');
+ const ti = ui.contextMenu.querySelector('#ctx_text_italic');
+ const tu = ui.contextMenu.querySelector('#ctx_text_underline');
+ if (tf) tf.value = String(state.textFontFamily || 'Arial');
+ if (ts) ts.value = String(Number(state.textFontSize) || 28);
+ if (tb) tb.style.background = (state.textFontWeight === 'bold') ? '#fb8c00' : '#37474f';
+ if (ti) ti.style.background = (state.textFontStyle === 'italic') ? '#fb8c00' : '#37474f';
+ if (tu) tu.style.background = state.textUnderline ? '#fb8c00' : '#37474f';
+ }
+ } catch {}
+
+ const el = document.createElement('textarea');
+ el.value = (typeof st.text === 'string') ? st.text : '';
+ el.spellcheck = false;
+ el.wrap = 'soft';
+ el.autocomplete = 'off';
+ el.autocapitalize = 'off';
+ el.rows = 1;
+ el.style.position = 'fixed';
+ el.style.zIndex = '100000';
+ el.style.padding = '6px';
+ el.style.margin = '0';
+ el.style.borderRadius = '8px';
+ el.style.border = '1px solid rgba(0,0,0,0.35)';
+ el.style.background = 'rgba(255,255,255,0.92)';
+ el.style.color = String(st.textColor || state.color || state.textColor || '#111111');
+ el.style.fontFamily = String(st.fontFamily || state.textFontFamily || 'Arial');
+ el.style.fontSize = String((Number(st.fontSize) || Number(state.textFontSize) || 28)) + 'px';
+ el.style.fontWeight = String(st.fontWeight || state.textFontWeight || 'normal');
+ el.style.fontStyle = String(st.fontStyle || state.textFontStyle || 'normal');
+ el.style.textDecoration = (st.underline ?? state.textUnderline) ? 'underline' : 'none';
+ el.style.lineHeight = '1.2';
+ el.style.resize = 'none';
+ el.style.overflow = 'hidden';
+ el.style.outline = 'none';
+
+ // Prevent ComfyUI shortcuts while typing
+ el.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Enter' && !ev.shiftKey) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ closeTextEditor({ commit: true });
+ } else if (ev.key === 'Escape') {
+ ev.preventDefault();
+ ev.stopPropagation();
+ closeTextEditor({ commit: false });
+ }
+ }, true);
+ el.addEventListener('pointerdown', (ev) => {
+ ev.stopPropagation();
+ }, true);
+ // Blur can happen transiently (e.g. focus shuffles while dragging the viewport).
+ // Defer close slightly and cancel if focus returns.
+ el.addEventListener('focus', () => {
+ try {
+ if (state._textEditorBlurTimer) {
+ clearTimeout(state._textEditorBlurTimer);
+ state._textEditorBlurTimer = null;
+ }
+ } catch {}
+ }, true);
+ el.addEventListener('blur', () => {
+ try {
+ if (state._textEditorBlurTimer) clearTimeout(state._textEditorBlurTimer);
+ state._textEditorBlurTimer = setTimeout(() => {
+ try {
+ // Editor may have been closed/reopened in the meantime
+ if (state.textEditor?.el !== el) return;
+ if (document.activeElement === el) return;
+ closeTextEditor({ commit: true });
+ } catch {}
+ }, 60);
+ } catch {}
+ });
+
+ document.body.appendChild(el);
+ state.textEditor = { stickerId, el };
+ try { syncTextEditorPosition(canvas); } catch {}
+ try { el.focus(); } catch {}
+ try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
+ }
+
+ function syncTextEditorPosition(canvas) {
+ const te = state.textEditor;
+ if (!te || !te.el) return;
+ const idx = getStickerIdxById(te.stickerId);
+ const st = idx >= 0 ? state.stickers[idx] : null;
+ if (!st || !isTextSticker(st)) {
+ closeTextEditor({ commit: true });
+ return;
+ }
+ // Keep editor font size aligned with zoom (so it matches rendered text)
+ try {
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ const base = (Number(st.fontSize) || Number(state.textFontSize) || 28);
+ te.el.style.fontSize = String(Math.max(8, base * sc)) + 'px';
+ } catch {}
+ const dpr = getCanvasEffectiveDpr(canvas);
+ const rPx = graphRectToCanvasPxRect({ x0: st.x, y0: st.y, x1: st.x + st.w, y1: st.y + st.h }, canvas);
+ if (!rPx) return;
+ const pad = 2;
+ te.el.style.left = Math.round((rPx.x / dpr) + pad) + 'px';
+ te.el.style.top = Math.round((rPx.y / dpr) + pad) + 'px';
+ te.el.style.width = Math.max(40, Math.round((rPx.w / dpr) - pad * 2)) + 'px';
+ te.el.style.height = Math.max(28, Math.round((rPx.h / dpr) - pad * 2)) + 'px';
+ }
+
+ function wrapTextLines(ctx, text, maxWidth) {
+ const s = String(text || '');
+ const rawLines = s.split(/\r?\n/);
+ const out = [];
+ for (const raw of rawLines) {
+ const line = String(raw);
+ if (!line) {
+ out.push('');
+ continue;
+ }
+ const words = line.split(/\s+/).filter(Boolean);
+ if (!words.length) {
+ out.push('');
+ continue;
+ }
+ let cur = words[0];
+ for (let i = 1; i < words.length; i++) {
+ const next = cur + ' ' + words[i];
+ if (ctx.measureText(next).width <= maxWidth) {
+ cur = next;
+ } else {
+ out.push(cur);
+ cur = words[i];
+ }
+ }
+ out.push(cur);
+ }
+ return out;
+ }
+
+ function stickerBBox(st) {
+ if (!st) return null;
+ const x = (typeof st.x === 'number') ? st.x : 0;
+ const y = (typeof st.y === 'number') ? st.y : 0;
+ const w = (typeof st.w === 'number') ? st.w : 0;
+ const h = (typeof st.h === 'number') ? st.h : 0;
+ if (w <= 0 || h <= 0) return null;
+ const rot = (typeof st.rot === 'number') ? st.rot : 0;
+ if (!rot) return { x0: x, y0: y, x1: x + w, y1: y + h };
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+ const c = Math.cos(rot);
+ const s = Math.sin(rot);
+ const localCorners = [
+ { x: -w / 2, y: -h / 2 },
+ { x: w / 2, y: -h / 2 },
+ { x: w / 2, y: h / 2 },
+ { x: -w / 2, y: h / 2 },
+ ];
+ let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
+ for (const k of localCorners) {
+ const gx = cx + (k.x * c - k.y * s);
+ const gy = cy + (k.x * s + k.y * c);
+ x0 = Math.min(x0, gx);
+ y0 = Math.min(y0, gy);
+ x1 = Math.max(x1, gx);
+ y1 = Math.max(y1, gy);
+ }
+ return { x0, y0, x1, y1 };
+ }
+
+ function selectionFromRect(rect) {
+ const r = normalizeRect(rect);
+ if (!r || r.w < 1e-6 || r.h < 1e-6) {
+ return { selectedPaths: [], selectedStickers: [], bbox: null };
+ }
+
+ const selectedPaths = [];
+ for (let li = 0; li < state.layers.length; li++) {
+ const layer = state.layers[li];
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ for (let pi = 0; pi < layer.paths.length; pi++) {
+ const p = layer.paths[pi];
+ const bb = pathBBox(p);
+ if (!bb) continue;
+ if (rectIntersects(r, bb)) selectedPaths.push({ layerIdx: li, pathIdx: pi, bbox: bb });
+ }
+ }
+
+ const selectedStickers = [];
+ for (let si = 0; si < (state.stickers?.length || 0); si++) {
+ const st = state.stickers[si];
+ const bb = stickerBBox(st);
+ if (!bb) continue;
+ if (rectIntersects(r, bb)) selectedStickers.push({ stickerIdx: si, bbox: bb });
+ }
+
+ let bbox = null;
+ for (const sp of selectedPaths) bbox = bboxUnion(bbox, sp.bbox);
+ for (const ss of selectedStickers) bbox = bboxUnion(bbox, ss.bbox);
+ return { selectedPaths, selectedStickers, bbox };
+ }
+
+ function selectionFromPolygon(poly) {
+ if (!Array.isArray(poly) || poly.length < 3) {
+ return { selectedPaths: [], selectedStickers: [], bbox: null };
+ }
+ // Use polygon bbox as a first pass, then point-in-polygon on representative points.
+ let pb = null;
+ for (const pt of poly) {
+ if (!pt) continue;
+ const bb = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y };
+ pb = bboxUnion(pb, bb);
+ }
+ if (!pb) return { selectedPaths: [], selectedStickers: [], bbox: null };
+
+ const selectedPaths = [];
+ for (let li = 0; li < state.layers.length; li++) {
+ const layer = state.layers[li];
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ for (let pi = 0; pi < layer.paths.length; pi++) {
+ const p = layer.paths[pi];
+ const bb = pathBBox(p);
+ if (!bb) continue;
+ if (!rectIntersects(pb, bb)) continue;
+ const c = rectCenter(bb);
+ if (c && pointInPolygon(c, poly)) selectedPaths.push({ layerIdx: li, pathIdx: pi, bbox: bb });
+ }
+ }
+
+ const selectedStickers = [];
+ for (let si = 0; si < (state.stickers?.length || 0); si++) {
+ const st = state.stickers[si];
+ const bb = stickerBBox(st);
+ if (!bb) continue;
+ if (!rectIntersects(pb, bb)) continue;
+ const c = rectCenter(bb);
+ if (c && pointInPolygon(c, poly)) selectedStickers.push({ stickerIdx: si, bbox: bb });
+ }
+
+ let bbox = null;
+ for (const sp of selectedPaths) bbox = bboxUnion(bbox, sp.bbox);
+ for (const ss of selectedStickers) bbox = bboxUnion(bbox, ss.bbox);
+ return { selectedPaths, selectedStickers, bbox };
+ }
+
+ function __iamccs_pointSegDist(p, a, b) {
+ const ax = a.x, ay = a.y;
+ const bx = b.x, by = b.y;
+ const px = p.x, py = p.y;
+ const abx = bx - ax;
+ const aby = by - ay;
+ const apx = px - ax;
+ const apy = py - ay;
+ const ab2 = abx * abx + aby * aby;
+ if (ab2 <= 1e-12) {
+ return Math.hypot(px - ax, py - ay);
+ }
+ let t = (apx * abx + apy * aby) / ab2;
+ if (t < 0) t = 0;
+ else if (t > 1) t = 1;
+ const cx = ax + abx * t;
+ const cy = ay + aby * t;
+ return Math.hypot(px - cx, py - cy);
+ }
+
+ function __iamccs_hitTestPathNearPoint(graphPos, canvas, { tolPx = 12 } = {}) {
+ try {
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ const tolGBase = Math.max(0.5 / sc, (tolPx || 12) / sc);
+ const hideNonLocked = !!state.hidden;
+
+ let best = null;
+ let bestDist = Infinity;
+
+ for (let li = (state.layers?.length || 0) - 1; li >= 0; li--) {
+ const layer = state.layers?.[li];
+ if (!layer || layer.visible === false || !Array.isArray(layer.paths)) continue;
+ if (hideNonLocked && !layer.locked) continue;
+ for (let pi = layer.paths.length - 1; pi >= 0; pi--) {
+ const path = layer.paths[pi];
+ if (!path || !Array.isArray(path.points) || path.points.length < 1) continue;
+ // UX: ignore eraser strokes for click-to-select (they can create tiny bboxes).
+ if (path.mode === 'erase') continue;
+ const bb = pathBBox(path);
+ if (!bb) continue;
+ const bbExp = { x0: bb.x0 - tolGBase, y0: bb.y0 - tolGBase, x1: bb.x1 + tolGBase, y1: bb.y1 + tolGBase };
+ if (!pointInRect(graphPos, bbExp)) continue;
+
+ const w = (typeof path.width === 'number') ? path.width : 3;
+ const strokeWg = state.constantScreen ? (w / sc) : w;
+ const tolG = tolGBase + Math.max(1e-6, strokeWg * 0.5);
+
+ let minD = Infinity;
+ if (path.points.length === 1) {
+ minD = Math.hypot(graphPos.x - path.points[0].x, graphPos.y - path.points[0].y);
+ } else {
+ for (let i = 0; i < path.points.length - 1; i++) {
+ const a = path.points[i];
+ const b = path.points[i + 1];
+ const d = __iamccs_pointSegDist(graphPos, a, b);
+ if (d < minD) minD = d;
+ if (minD <= tolG) break;
+ }
+ }
+ if (minD <= tolG && minD < bestDist) {
+ bestDist = minD;
+ best = { layerIdx: li, pathIdx: pi, bbox: bb };
+ }
+ }
+ }
+ return best;
+ } catch {
+ return null;
+ }
+ }
+
+ function __iamccs_selectSingleAtPoint(graphPos, canvas) {
+ if (!graphPos || !canvas) return null;
+ if (state.hidden) return null;
+
+ // Prefer stickers (topmost first)
+ try {
+ for (let si = (state.stickers?.length || 0) - 1; si >= 0; si--) {
+ const st = state.stickers?.[si];
+ const bb = stickerBBox(st);
+ if (!bb) continue;
+ if (pointInRect(graphPos, bb)) {
+ return {
+ kind: 'select',
+ mode: 'rect',
+ points: [],
+ rect: null,
+ bbox: bb,
+ selectedPaths: [],
+ selectedStickers: [{ stickerIdx: si, bbox: bb }],
+ dragging: false,
+ pendingDrag: false,
+ creating: false,
+ };
+ }
+ }
+ } catch {}
+
+ // Then paths near point
+ const hitP = __iamccs_hitTestPathNearPoint(graphPos, canvas, { tolPx: 12 });
+ if (hitP) {
+ let bb = hitP.bbox || null;
+ // Expand bbox by stroke width (so the visible selection frame matches what you see on screen)
+ try {
+ const ds = canvas?.ds || canvas?.viewport || { scale: canvas?.scale || 1 };
+ const sc = (ds.scale || 1);
+ const layer = state.layers?.[hitP.layerIdx];
+ const path = layer?.paths?.[hitP.pathIdx];
+ const w = (typeof path?.width === 'number') ? path.width : 3;
+ const strokeWg = state.constantScreen ? (w / sc) : w;
+ const pad = Math.max(1.5 / sc, Math.max(1e-6, strokeWg * 0.5));
+ if (bb) bb = { x0: bb.x0 - pad, y0: bb.y0 - pad, x1: bb.x1 + pad, y1: bb.y1 + pad };
+ } catch {}
+ return {
+ kind: 'select',
+ mode: 'rect',
+ points: [],
+ rect: null,
+ bbox: bb,
+ selectedPaths: [{ layerIdx: hitP.layerIdx, pathIdx: hitP.pathIdx, bbox: bb }],
+ selectedStickers: [],
+ dragging: false,
+ pendingDrag: false,
+ creating: false,
+ };
+ }
+ return null;
+ }
+
+ function clearSelection() {
+ state.selection = null;
+ app?.canvas?.setDirty(true, true);
+ }
+
+ function deleteSelection({ allowLocked = false } = {}) {
+ const sel = state.selection;
+ if (!sel) return false;
+
+ const isPinnedStickerId = (sid) => {
+ if (!sid) return false;
+ try {
+ for (const st of (state.stickers || [])) {
+ if (st?.id === sid) return !!st.pinned;
+ }
+ } catch {}
+ return false;
+ };
+
+ // Determine what we can actually delete (so we don't create undo entries when blocked)
+ const stickerIdxsToDelete = [];
+ const deletableStickerIds = new Set();
+ if (Array.isArray(sel.selectedStickers) && sel.selectedStickers.length) {
+ for (const it of sel.selectedStickers) {
+ const st = (typeof it?.stickerIdx === 'number') ? state.stickers?.[it.stickerIdx] : null;
+ if (!st || st.pinned) continue;
+ if (typeof it?.stickerIdx === 'number') stickerIdxsToDelete.push(it.stickerIdx);
+ if (st?.id) deletableStickerIds.add(st.id);
+ }
+ }
+
+ const byLayer = new Map();
+ for (const it of (sel.selectedPaths || [])) {
+ if (typeof it?.layerIdx !== 'number' || typeof it?.pathIdx !== 'number') continue;
+ const layer = state.layers?.[it.layerIdx];
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ if (layer.locked && !allowLocked) continue;
+ const path = layer.paths?.[it.pathIdx];
+ if (!path) continue;
+ // Do not delete paths anchored to a pinned sticker (treat pinned as protected)
+ if (path?.parentStickerId && isPinnedStickerId(path.parentStickerId)) continue;
+ if (!byLayer.has(it.layerIdx)) byLayer.set(it.layerIdx, []);
+ byLayer.get(it.layerIdx).push(it.pathIdx);
+ }
+
+ if (!stickerIdxsToDelete.length && !byLayer.size) return false;
+
+ // Undo snapshot before destructive operation
+ pushHistorySnapshot();
+
+ // If we're editing a text sticker and it gets deleted, close editor first
+ try {
+ if (state.textEditor?.stickerId && deletableStickerIds?.has?.(state.textEditor.stickerId)) {
+ closeTextEditor({ commit: true });
+ }
+ } catch {}
+
+ // Delete stickers first (descending indices). Pinned stickers are not deletable.
+ const deletedStickerIds = new Set();
+ try {
+ for (const sid of deletableStickerIds) deletedStickerIds.add(sid);
+ } catch {}
+
+ // Best-effort cleanup of screenshot blobs in IndexedDB
+ try {
+ for (const it of (sel.selectedStickers || [])) {
+ const st = (typeof it?.stickerIdx === 'number') ? state.stickers?.[it.stickerIdx] : null;
+ if (!st || isTextSticker(st)) continue;
+ if (st.dataKey) {
+ try { __idbDel(__IAMCCS_IDB_STORE_STICKERS, st.dataKey); } catch {}
+ }
+ }
+ } catch {}
+
+ const idxs = stickerIdxsToDelete
+ .filter(i => typeof i === 'number')
+ .sort((a, b) => b - a);
+ for (const i of idxs) {
+ if (i >= 0 && i < state.stickers.length) state.stickers.splice(i, 1);
+ }
+
+ // If a parent sticker was deleted, also delete any anchored children (paths + stickers)
+ if (deletedStickerIds.size) {
+ try {
+ // Stickers
+ state.stickers = (state.stickers || []).filter(st => {
+ if (!st) return false;
+ const pid = st.parentStickerId;
+ return !(typeof pid === 'string' && pid && deletedStickerIds.has(pid));
+ });
+ } catch {}
+ try {
+ // Paths
+ for (const layer of (state.layers || [])) {
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ layer.paths = layer.paths.filter(p => {
+ if (!p) return false;
+ const pid = p.parentStickerId;
+ return !(typeof pid === 'string' && pid && deletedStickerIds.has(pid));
+ });
+ }
+ } catch {}
+ }
+
+ // Delete paths grouped by layer (descending path indices)
+ for (const [li, pathIdxs] of byLayer.entries()) {
+ const layer = state.layers[li];
+ if (!layer || !Array.isArray(layer.paths)) continue;
+ pathIdxs.sort((a, b) => b - a);
+ for (const pi of pathIdxs) {
+ if (pi >= 0 && pi < layer.paths.length) layer.paths.splice(pi, 1);
+ }
+ }
+
+ // Refresh flat paths
+ state.paths = [];
+ for (const lyr of state.layers) for (const p of (lyr.paths || [])) state.paths.push(p);
+
+ clearSelection();
+ persistToGraphExtra(true);
+ return true;
+ }
+
+ function copySelectionToClipboard(opts) {
+ const sel = state.selection;
+ if (!sel) return false;
+
+ const clearAfter = opts?.clearAfter !== false;
+
+ const clipPaths = [];
+ for (const it of (sel.selectedPaths || [])) {
+ const layer = state.layers[it.layerIdx];
+ const p = layer?.paths?.[it.pathIdx];
+ if (!p) continue;
+ clipPaths.push({ layerIdx: it.layerIdx, path: clonePath(p) });
+ }
+ const clipStickers = [];
+ for (const it of (sel.selectedStickers || [])) {
+ const st = state.stickers?.[it.stickerIdx];
+ if (!st) continue;
+ clipStickers.push(cloneSticker(st));
+ }
+
+ state.clipboard = {
+ paths: clipPaths,
+ stickers: clipStickers,
+ bbox: sel.bbox ? { ...sel.bbox } : null,
+ };
+
+ // Requirement: after copy the selection frame should disappear
+ if (clearAfter) clearSelection();
+ return true;
+ }
+
+ function pasteClipboardAt(pos) {
+ const clip = state.clipboard;
+ if (!clip || (!clip.paths?.length && !clip.stickers?.length)) return false;
+
+ pushHistorySnapshot();
+
+ const dst = pos || state.lastPointerGraphPos || null;
+ const srcB = clip.bbox;
+ const dx = (dst && srcB) ? (dst.x - srcB.x0) : 20;
+ const dy = (dst && srcB) ? (dst.y - srcB.y0) : 20;
+
+ // Remap sticker IDs so anchored children in the pasted group keep pointing to the pasted parent
+ const idMap = new Map();
+ try {
+ for (const st0 of (clip.stickers || [])) {
+ const oldId = st0?.id;
+ if (!oldId || idMap.has(oldId)) continue;
+ idMap.set(oldId, `st_${Date.now()}_${Math.random().toString(16).slice(2)}`);
+ }
+ } catch {}
+
+ // Paste paths into their original layers when possible
+ for (const it of clip.paths || []) {
+ const li = it.layerIdx;
+ const layer = state.layers[li] || state.layers[state.currentLayerIdx];
+ if (!layer || layer.locked) continue;
+ const p = clonePath(it.path);
+ try {
+ if (p?.parentStickerId && idMap.has(p.parentStickerId)) p.parentStickerId = idMap.get(p.parentStickerId);
+ } catch {}
+ for (const pt of p.points) {
+ pt.x += dx;
+ pt.y += dy;
+ }
+ layer.paths.push(p);
+ state.paths.push(p);
+ }
+
+ // Paste stickers
+ for (const st0 of clip.stickers || []) {
+ const st = cloneSticker(st0);
+ try {
+ const oldId = st.id;
+ if (oldId && idMap.has(oldId)) st.id = idMap.get(oldId);
+ if (st.parentStickerId && idMap.has(st.parentStickerId)) st.parentStickerId = idMap.get(st.parentStickerId);
+ } catch {}
+ st.x += dx;
+ st.y += dy;
+ state.stickers.push(st);
+ }
+
+ app?.canvas?.setDirty(true, true);
+ persistToGraphExtra(true);
+ return true;
+ }
+
+ // --- Interop with ComfyUI/LiteGraph node clipboard (Ctrl+C/Ctrl+V on nodes) ---
+ // ComfyUI's node clipboard may serialize the whole graph, including `graph.extra`.
+ // This extension stores drawings/screenshots in `graph.extra`, so without filtering
+ // they get copied alongside nodes. Correct behavior: include notes only when
+ // explicitly selected AND Annotate is ON.
+
+ function __iamccs_hasSelectedAnnotations() {
+ const sel = state.selection;
+ if (!sel || sel.kind !== 'select') return false;
+ return ((sel.selectedPaths?.length || 0) > 0) || ((sel.selectedStickers?.length || 0) > 0);
+ }
+
+ function __iamccs_buildSelectedAnnotationsClip() {
+ const sel = state.selection;
+ if (!sel || sel.kind !== 'select') return null;
+
+ const clipPaths = [];
+ for (const it of (sel.selectedPaths || [])) {
+ const layer = state.layers?.[it.layerIdx];
+ const p = layer?.paths?.[it.pathIdx];
+ if (!p) continue;
+ clipPaths.push({ layerIdx: it.layerIdx, path: clonePath(p) });
+ }
+ const clipStickers = [];
+ for (const it of (sel.selectedStickers || [])) {
+ const st = state.stickers?.[it.stickerIdx];
+ if (!st) continue;
+ clipStickers.push(cloneSticker(st));
+ }
+
+ if (!clipPaths.length && !clipStickers.length) return null;
+
+ return {
+ version: 1,
+ bbox: sel.bbox ? { ...sel.bbox } : null,
+ paths: clipPaths,
+ stickers: clipStickers,
+ };
+ }
+
+ function __iamccs_pasteAnnotationsClip(clip, { dx = 20, dy = 20 } = {}) {
+ if (!clip || (!clip.paths?.length && !clip.stickers?.length)) return false;
+
+ // Undo snapshot for annotations (node paste has its own undo stack)
+ try { pushHistorySnapshot(); } catch {}
+
+ // Remap sticker IDs so anchored children in the pasted group keep pointing to the pasted parent
+ const idMap = new Map();
+ try {
+ for (const st0 of (clip.stickers || [])) {
+ const oldId = st0?.id;
+ if (!oldId || idMap.has(oldId)) continue;
+ idMap.set(oldId, `st_${Date.now()}_${Math.random().toString(16).slice(2)}`);
+ }
+ } catch {}
+
+ // Paste paths
+ for (const it of clip.paths || []) {
+ const li = it.layerIdx;
+ const layer = state.layers?.[li] || state.layers?.[state.currentLayerIdx];
+ if (!layer || layer.locked) continue;
+ const p = clonePath(it.path);
+ try {
+ if (p?.parentStickerId && idMap.has(p.parentStickerId)) p.parentStickerId = idMap.get(p.parentStickerId);
+ } catch {}
+ for (const pt of p.points || []) {
+ pt.x += dx;
+ pt.y += dy;
+ }
+ layer.paths.push(p);
+ state.paths.push(p);
+ }
+
+ // Paste stickers
+ for (const st0 of clip.stickers || []) {
+ const st = cloneSticker(st0);
+ try {
+ const oldId = st.id;
+ if (oldId && idMap.has(oldId)) st.id = idMap.get(oldId);
+ if (st.parentStickerId && idMap.has(st.parentStickerId)) st.parentStickerId = idMap.get(st.parentStickerId);
+ } catch {}
+ st.x += dx;
+ st.y += dy;
+ state.stickers.push(st);
+ }
+
+ app?.canvas?.setDirty(true, true);
+ persistToGraphExtra(true);
+ return true;
+ }
+
+ function __iamccs_stripAnnotationsFromClipboardData(data) {
+ const STRIP_KEYS = new Set([
+ 'iamccs_annotations',
+ 'iamccs_annotations_multi',
+ 'iamccs_annotations_clip',
+ // Used to namespace large blobs for this workflow; must not hitchhike on node clipboard.
+ 'iamccs_annotate_blob_ns',
+ ]);
+
+ function deepStrip(obj, depth, seen) {
+ try {
+ if (!obj || typeof obj !== 'object') return;
+ if (seen.has(obj)) return;
+ seen.add(obj);
+ if (depth > 10) return;
+
+ // Strip direct keys on objects
+ for (const k of Object.keys(obj)) {
+ if (STRIP_KEYS.has(k)) {
+ try { delete obj[k]; } catch {}
+ continue;
+ }
+ const v = obj[k];
+ if (v && typeof v === 'object') deepStrip(v, depth + 1, seen);
+ }
+ } catch {}
+ }
+
+ try {
+ deepStrip(data, 0, new WeakSet());
+ } catch {}
+ return data;
+ }
+
+ function __iamccs_nodesClipboardBBox(data) {
+ try {
+ const nodes = Array.isArray(data?.nodes) ? data.nodes : [];
+ let x0 = Infinity, y0 = Infinity;
+ for (const n of nodes) {
+ const pos = n?.pos || n?.position;
+ if (Array.isArray(pos) && pos.length >= 2) {
+ const x = Number(pos[0]);
+ const y = Number(pos[1]);
+ if (isFinite(x)) x0 = Math.min(x0, x);
+ if (isFinite(y)) y0 = Math.min(y0, y);
+ }
+ }
+ if (!isFinite(x0) || !isFinite(y0)) return null;
+ return { x0, y0 };
+ } catch {
+ return null;
+ }
+ }
+
+ function installNodeClipboardHooks() {
+ try {
+ if (window.__IAMCCS_NODE_CLIPBOARD_HOOKED) return;
+
+ const LG = window.LiteGraph;
+ const CanvasProto = window.LGraphCanvas?.prototype || LG?.LGraphCanvas?.prototype;
+ if (!LG || !CanvasProto) return;
+
+ const origCopy = CanvasProto.copyToClipboard;
+ const origPaste = CanvasProto.pasteFromClipboard;
+ if (typeof origCopy !== 'function' || typeof origPaste !== 'function') return;
+
+ CanvasProto.copyToClipboard = function (...args) {
+ const r = origCopy.apply(this, args);
+ try {
+ const s = LG.clipboard;
+ if (typeof s !== 'string' || !s.length) return r;
+ let data;
+ try { data = JSON.parse(s); } catch { return r; }
+
+ // Always remove full-workflow annotations from the node clipboard
+ data = __iamccs_stripAnnotationsFromClipboardData(data);
+
+ // IMPORTANT: never attach notes/screenshots to the node clipboard.
+ delete data.iamccs_annotations_clip;
+
+ LG.clipboard = JSON.stringify(data);
+ } catch {}
+ return r;
+ };
+
+ CanvasProto.pasteFromClipboard = function (...args) {
+ // Defensive: ensure node clipboard never contains our workflow annotations.
+ try {
+ const s = LG.clipboard;
+ if (typeof s === 'string' && s.length) {
+ let data;
+ try { data = JSON.parse(s); } catch { data = null; }
+ if (data) {
+ data = __iamccs_stripAnnotationsFromClipboardData(data);
+ delete data.iamccs_annotations_clip;
+ LG.clipboard = JSON.stringify(data);
+ }
+ }
+ } catch {}
+
+ // Paste nodes only (native behavior)
+ return origPaste.apply(this, args);
+ };
+
+ window.__IAMCCS_NODE_CLIPBOARD_HOOKED = true;
+ console.log('[IAMCCS] Node clipboard hooks installed');
+ } catch (e) {
+ console.warn('[IAMCCS] installNodeClipboardHooks failed:', e);
+ }
+ }
// Written code by Carmine Cristallo Scalzi (IAMCCS) - AI for debugging - section: Draggable Utility - reason: clarifies helper enabling persistent movable UI panels/buttons
function makeDraggable(el, { storageKey, handle, isFixed = true, onDrag, onDragStart }) {
const target = handle || el;
@@ -127,12 +2092,16 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
if (ui.btn) {
ui.btn.textContent = state.enabled ? 'ENABLED' : 'DISABLED';
ui.btn.style.background = state.enabled ? '#4CAF50' : '#f44336';
+ // Persistent ON badge
+ try { __iamccsSetCornerBadge(ui.btn, 'enabled', !!state.enabled, 'ON', { bg: '#ff9800' }); } catch {}
}
// Floating button
if (ui.floating) {
ui.floating.textContent = state.enabled ? 'Annotate: ON' : 'Annotate: OFF';
ui.floating.style.background = state.enabled ? '#2e7d32' : '#9e2b25';
ui.floating.style.borderColor = state.enabled ? '#66bb6a' : '#ef5350';
+ // Persistent ON badge
+ try { __iamccsSetCornerBadge(ui.floating, 'enabled', !!state.enabled, 'ON', { bg: '#ff9800' }); } catch {}
}
// Sync context menu if open
if (ui.contextMenu && ui.contextMenu.parentElement) {
@@ -140,11 +2109,28 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
if (tgl) {
tgl.textContent = state.enabled ? 'ENABLED' : 'DISABLED';
tgl.style.background = state.enabled ? '#4CAF50' : '#f44336';
+ // Persistent ON badge
+ try { __iamccsSetCornerBadge(tgl, 'enabled', !!state.enabled, 'ON', { bg: '#ff9800' }); } catch {}
}
}
}
function syncFlagsUI() {
+ const syncToggleBadgesUI = () => {
+ try {
+ const showPen = !!state.penOnly;
+ for (const el of document.querySelectorAll('[data-iam-penonly-badge]')) el.style.display = showPen ? 'inline-flex' : 'none';
+
+ const showSel = !!state.selOverride;
+ for (const el of document.querySelectorAll('[data-iam-sel-badge]')) el.style.display = showSel ? 'inline-flex' : 'none';
+
+ const showPin = !!state.pinMode;
+ for (const el of document.querySelectorAll('[data-iam-pin-badge]')) el.style.display = showPin ? 'inline-flex' : 'none';
+
+ const showHidden = !!state.hidden;
+ for (const el of document.querySelectorAll('[data-iam-hidden-badge]')) el.style.display = showHidden ? 'inline-flex' : 'none';
+ } catch {}
+ };
// Sync checkboxes in sidebar/dock
try {
const dashed = document.getElementById('iam_dashed');
@@ -157,6 +2143,10 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
if (hidden) hidden.checked = !!state.hidden;
const penonly = document.getElementById('iam_penonly');
if (penonly) penonly.checked = !!state.penOnly;
+ const pinm = document.getElementById('iam_pinmode');
+ if (pinm) pinm.checked = !!state.pinMode;
+ const selovr = document.getElementById('iam_sel_override');
+ if (selovr) selovr.checked = !!state.selOverride;
} catch {}
// Sync checkboxes in context menu
try {
@@ -171,12 +2161,51 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
if (hid) hid.checked = !!state.hidden;
const pen = ui.contextMenu.querySelector('#ctx_penonly');
if (pen) pen.checked = !!state.penOnly;
+ const pinm = ui.contextMenu.querySelector('#ctx_pinmode');
+ if (pinm) pinm.checked = !!state.pinMode;
+ const selovr = ui.contextMenu.querySelector('#ctx_sel_override');
+ if (selovr) selovr.checked = !!state.selOverride;
}
} catch {}
+
+ // Keep always-on indicators in sync
+ syncToggleBadgesUI();
}
function setEnabled(val) {
+ const prev = !!state.enabled;
state.enabled = !!val;
+ if (!state.enabled && state.pinMode) {
+ state.pinMode = false;
+ }
+ if (!state.enabled) {
+ try { closeTextEditor({ commit: true }); } catch {}
+ }
+ // When disabling, ensure we stop consuming pointer events (so ComfyUI can pan/drag the workflow).
+ // Keep sticker border drag available via the early hit-test path in the pointerdown handler.
+ if (!state.enabled) {
+ try {
+ const el = app?.canvas?.canvas;
+ const pid = state.activePointerId;
+ if (el && typeof el.releasePointerCapture === 'function' && typeof pid === 'number') {
+ try { el.releasePointerCapture(pid); } catch {}
+ }
+ } catch {}
+ state.activePointerId = null;
+ state.pendingSelection = null;
+ state.stickerDrag = null;
+ state.stickerResize = null;
+ state.transformDrag = null;
+ state.rotateDrag = null;
+ if (!state.selOverride) state.selection = null;
+ }
+ // Ensure the floating toggle exists even after workflow reload/DOM resets
+ try {
+ if (!ui.floating || !document.body.contains(ui.floating)) {
+ ensureFloatingToggle();
+ state.uiShown = true;
+ }
+ } catch {}
// If disabling while drawing, finish current stroke gracefully
if (!state.enabled && state.current) {
state.paths.push(state.current);
@@ -185,6 +2214,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
// Preserve user brush settings; but on enable force dashed back to off per requirement
if (state.enabled && state.dashed) {
state.dashed = false;
+ try { saveStateToLayerStyle(undefined, { persist: true }); } catch {}
syncFlagsUI();
}
// Keep current color, width, opacity, constant width, and mode choices intact
@@ -196,10 +2226,115 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
syncBrushOpacityUI();
syncFlagsUI();
syncUI();
+ syncToolsUI();
app?.canvas?.setDirty(true, true);
+ try {
+ if (prev !== !!state.enabled) {
+ showToast(state.enabled ? 'Annotate enabled' : 'Annotate disabled', { kind: 'info', ms: 1100 });
+ }
+ } catch {}
console.log('[IAMCCS] Annotate:', state.enabled ? 'ON' : 'OFF');
}
+ function setTool(next) {
+ const t = (next === 'select' || next === 'transform' || next === 'rotate' || next === 'screenshot' || next === 'text' || next === 'draw') ? next : 'draw';
+ state.tool = t;
+ // Cancel any in-progress stroke when leaving draw
+ if (t !== 'draw') state.current = null;
+ // Clear selection when leaving select/screenshot
+ if (t === 'draw') state.selection = null;
+ // Clear a pending text placement rectangle if leaving text
+ if (t !== 'text' && state.selection?.kind === 'text') state.selection = null;
+ state.pendingSelection = null;
+ // Reset pointer id in case a capture was active
+ state.activePointerId = null;
+ state.stickerDrag = null;
+ state.stickerResize = null;
+ state.transformDrag = null;
+ state.rotateDrag = null;
+ syncToolsUI();
+ app?.canvas?.setDirty(true, true);
+ }
+
+ function setTransformMode(mode) {
+ const m = (mode === 'freeform' || mode === 'fixed') ? mode : 'fixed';
+ state.transformMode = m;
+ syncToolsUI();
+ app?.canvas?.setDirty(true, true);
+ }
+
+ function setSelectMode(mode) {
+ const m0 = (mode === 'free') ? 'lasso' : mode;
+ const m = (m0 === 'rect' || m0 === 'lasso') ? m0 : 'rect';
+ state.selectMode = m;
+ syncToolsUI();
+ app?.canvas?.setDirty(true, true);
+ }
+
+ function __iamccsEnsureCornerBadge(btn, key) {
+ if (!btn) return null;
+ try {
+ if (!btn.style.position || btn.style.position === 'static') btn.style.position = 'relative';
+ } catch {}
+ let badge = null;
+ try { badge = btn.querySelector(`span[data-iam-corner-badge="${key}"]`); } catch {}
+ if (badge) return badge;
+ try {
+ badge = document.createElement('span');
+ badge.dataset.iamCornerBadge = key;
+ badge.style.cssText = [
+ 'position:absolute',
+ 'top:-7px',
+ 'right:-7px',
+ 'display:none',
+ 'align-items:center',
+ 'justify-content:center',
+ 'height:16px',
+ 'min-width:16px',
+ 'padding:0 6px',
+ 'border-radius:999px',
+ 'background:#ff9800',
+ 'color:#111',
+ 'font-weight:900',
+ 'font-size:10px',
+ 'letter-spacing:0.6px',
+ 'border:1px solid rgba(0,0,0,0.35)',
+ 'box-shadow:0 1px 2px rgba(0,0,0,0.25)',
+ 'pointer-events:none',
+ 'user-select:none',
+ 'z-index:5',
+ ].join(';');
+ badge.textContent = 'ON';
+ btn.appendChild(badge);
+ } catch {}
+ return badge;
+ }
+
+ function __iamccsSetCornerBadge(btn, key, show, text, opts) {
+ const badge = __iamccsEnsureCornerBadge(btn, key);
+ if (!badge) return;
+ try {
+ if (typeof text === 'string') badge.textContent = text;
+ badge.style.display = show ? 'inline-flex' : 'none';
+ if (opts?.bg) badge.style.background = opts.bg;
+ if (opts?.color) badge.style.color = opts.color;
+ } catch {}
+ }
+
+ function __iamccsFlashCornerBadge(btn, key, text, opts) {
+ if (!btn) return;
+ const ms = Math.max(200, Math.min(2500, Number(opts?.ms) || 900));
+ __iamccsSetCornerBadge(btn, key, true, text, opts);
+ try {
+ if (!btn.__iamBadgeTimers) btn.__iamBadgeTimers = {};
+ if (btn.__iamBadgeTimers[key]) clearTimeout(btn.__iamBadgeTimers[key]);
+ btn.__iamBadgeTimers[key] = setTimeout(() => {
+ try { __iamccsSetCornerBadge(btn, key, false); } catch {}
+ try { btn.__iamBadgeTimers[key] = null; } catch {}
+ }, ms);
+ } catch {}
+ }
+
function syncBrushOpacityUI() {
// Sidebar/dock
const sec = document.getElementById('iamccs-sidebar');
@@ -224,6 +2359,165 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
}
}
+ function syncColorUI() {
+ try {
+ const c = String(state.color || '#ff4444');
+ // Sidebar / dock (only one exists at a time)
+ try {
+ const sec = document.getElementById('iamccs-sidebar');
+ const el = sec?.querySelector('#iam_color');
+ if (el && el.value !== c) el.value = c;
+ } catch {}
+ // Context menu (if open)
+ try {
+ const el = ui.contextMenu?.querySelector?.('#ctx_color');
+ if (el && el.value !== c) el.value = c;
+ } catch {}
+ } catch {}
+ }
+
+ function applyMacroSizing() {
+ // Macro UI removed.
+ }
+
+ function syncToolsUI() {
+ try {
+ const panel = document.getElementById('iamccs-sidebar');
+ if (panel) {
+ const toolDrawBtn = panel.querySelector('#iam_tool_draw');
+ const toolSelectBtn = panel.querySelector('#iam_tool_select');
+ const toolTransformBtn = panel.querySelector('#iam_tool_transform');
+ const toolRotateBtn = panel.querySelector('#iam_tool_rotate');
+ const toolShotBtn = panel.querySelector('#iam_tool_shot');
+ const selMode = panel.querySelector('#iam_select_mode');
+ const tfMode = panel.querySelector('#iam_transform_mode');
+ const selClearBtn = panel.querySelector('#iam_sel_clear');
+ const selCopyBtn = panel.querySelector('#iam_sel_copy');
+ const selCutBtn = panel.querySelector('#iam_sel_cut');
+ const selPasteBtn = panel.querySelector('#iam_sel_paste');
+
+ const inactive = '#455a64';
+ const drawOn = '#43a047';
+ const selectOn = '#039be5';
+ const shotOn = '#8e24aa';
+ const isSelect = state.tool === 'select';
+ const isTransform = state.tool === 'transform';
+ const isRotate = state.tool === 'rotate';
+ const selAction = (isSelect || isTransform || isRotate) ? '#fb8c00' : '#37474f';
+
+ if (toolDrawBtn) toolDrawBtn.style.background = state.tool === 'draw' ? drawOn : inactive;
+ if (toolSelectBtn) toolSelectBtn.style.background = isSelect ? selectOn : inactive;
+ if (toolTransformBtn) toolTransformBtn.style.background = isTransform ? selectOn : inactive;
+ if (toolRotateBtn) toolRotateBtn.style.background = isRotate ? selectOn : inactive;
+ if (toolShotBtn) toolShotBtn.style.background = state.tool === 'screenshot' ? shotOn : inactive;
+
+ // Active tool badges (persistent)
+ __iamccsSetCornerBadge(toolDrawBtn, 'active', state.tool === 'draw', 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolSelectBtn, 'active', isSelect, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolTransformBtn, 'active', isTransform, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolRotateBtn, 'active', isRotate, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolShotBtn, 'active', state.tool === 'screenshot', 'ON', { bg: '#ff9800' });
+
+ // Eraser badge (persistent)
+ const er = panel.querySelector('#iam_eraser');
+ __iamccsSetCornerBadge(er, 'mode', !!state.eraser, 'ER', { bg: '#ff9800' });
+ for (const b of [selClearBtn, selCopyBtn, selCutBtn, selPasteBtn]) {
+ if (b) b.style.background = selAction;
+ }
+ if (selMode) selMode.value = state.selectMode || 'rect';
+ if (selMode) selMode.style.borderColor = (isSelect || isTransform || isRotate) ? selectOn : '#444';
+ if (selMode) selMode.style.display = (isSelect || isTransform || isRotate) ? '' : 'none';
+ if (tfMode) tfMode.value = state.transformMode || 'fixed';
+ if (tfMode) tfMode.style.borderColor = isTransform ? selectOn : '#444';
+ if (tfMode) tfMode.style.display = isTransform ? '' : 'none';
+ }
+ } catch {}
+ try {
+ if (ui.contextMenu) {
+ const toolDrawBtn = ui.contextMenu.querySelector('#ctx_tool_draw');
+ const toolSelectBtn = ui.contextMenu.querySelector('#ctx_tool_select');
+ const toolTransformBtn = ui.contextMenu.querySelector('#ctx_tool_transform');
+ const toolRotateBtn = ui.contextMenu.querySelector('#ctx_tool_rotate');
+ const toolShotBtn = ui.contextMenu.querySelector('#ctx_tool_shot');
+ const selMode = ui.contextMenu.querySelector('#ctx_select_mode');
+ const tfMode = ui.contextMenu.querySelector('#ctx_transform_mode');
+ const selClearBtn = ui.contextMenu.querySelector('#ctx_sel_clear');
+ const selCopyBtn = ui.contextMenu.querySelector('#ctx_sel_copy');
+ const selCutBtn = ui.contextMenu.querySelector('#ctx_sel_cut');
+ const selPasteBtn = ui.contextMenu.querySelector('#ctx_sel_paste');
+
+ const inactive = '#455a64';
+ const drawOn = '#43a047';
+ const selectOn = '#039be5';
+ const shotOn = '#8e24aa';
+ const isSelect = state.tool === 'select';
+ const isTransform = state.tool === 'transform';
+ const isRotate = state.tool === 'rotate';
+ const selAction = (isSelect || isTransform || isRotate) ? '#fb8c00' : '#37474f';
+
+ if (toolDrawBtn) toolDrawBtn.style.background = state.tool === 'draw' ? drawOn : inactive;
+ if (toolSelectBtn) toolSelectBtn.style.background = isSelect ? selectOn : inactive;
+ if (toolTransformBtn) toolTransformBtn.style.background = isTransform ? selectOn : inactive;
+ if (toolRotateBtn) toolRotateBtn.style.background = isRotate ? selectOn : inactive;
+ if (toolShotBtn) toolShotBtn.style.background = state.tool === 'screenshot' ? shotOn : inactive;
+
+ // Active tool badges (persistent)
+ __iamccsSetCornerBadge(toolDrawBtn, 'active', state.tool === 'draw', 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolSelectBtn, 'active', isSelect, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolTransformBtn, 'active', isTransform, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolRotateBtn, 'active', isRotate, 'ON', { bg: '#ff9800' });
+ __iamccsSetCornerBadge(toolShotBtn, 'active', state.tool === 'screenshot', 'ON', { bg: '#ff9800' });
+ for (const b of [selClearBtn, selCopyBtn, selCutBtn, selPasteBtn]) {
+ if (b) b.style.background = selAction;
+ }
+ if (selMode) selMode.value = state.selectMode || 'rect';
+ if (selMode) selMode.style.borderColor = (isSelect || isTransform || isRotate) ? selectOn : '#444';
+ if (selMode) selMode.style.display = (isSelect || isTransform || isRotate) ? '' : 'none';
+ if (tfMode) tfMode.value = state.transformMode || 'fixed';
+ if (tfMode) tfMode.style.borderColor = isTransform ? selectOn : '#444';
+ if (tfMode) tfMode.style.display = isTransform ? '' : 'none';
+ }
+ } catch {}
+ }
+
+ function syncStickerUI() {
+ try {
+ const panel = document.getElementById('iamccs-sidebar');
+ if (panel) {
+ const c = panel.querySelector('#iam_sticker_color');
+ const pad = panel.querySelector('#iam_sticker_pad');
+ const bw = panel.querySelector('#iam_sticker_border');
+ const sh = panel.querySelector('#iam_sticker_shadow');
+ const ss = panel.querySelector('#iam_sticker_shadow_strength');
+ if (c) c.value = String(state.stickerFrameColor || '#ffffff');
+ if (pad) pad.value = String(Number(state.stickerPaddingPx) || 0);
+ if (bw) bw.value = String(Number(state.stickerBorderWidthPx) || 0);
+ if (sh) sh.checked = !!state.stickerShadow;
+ if (ss) {
+ ss.value = String(Number(state.stickerShadowStrength) || 0);
+ ss.disabled = !state.stickerShadow;
+ }
+ }
+ } catch {}
+ try {
+ if (ui.contextMenu) {
+ const c = ui.contextMenu.querySelector('#ctx_sticker_color');
+ const pad = ui.contextMenu.querySelector('#ctx_sticker_pad');
+ const bw = ui.contextMenu.querySelector('#ctx_sticker_border');
+ const sh = ui.contextMenu.querySelector('#ctx_sticker_shadow');
+ const ss = ui.contextMenu.querySelector('#ctx_sticker_shadow_strength');
+ if (c) c.value = String(state.stickerFrameColor || '#ffffff');
+ if (pad) pad.value = String(Number(state.stickerPaddingPx) || 0);
+ if (bw) bw.value = String(Number(state.stickerBorderWidthPx) || 0);
+ if (sh) sh.checked = !!state.stickerShadow;
+ if (ss) {
+ ss.value = String(Number(state.stickerShadowStrength) || 0);
+ ss.disabled = !state.stickerShadow;
+ }
+ }
+ } catch {}
+ }
+
function setEraserMode(on) {
const next = !!on;
if (state.eraser === next) return;
@@ -252,6 +2546,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
}
syncBrushOpacityUI();
app?.canvas?.setDirty(true, true);
+ try { persistToGraphExtra(); } catch {}
}
function findSidebarElement() {
@@ -307,6 +2602,53 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {