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. -![Title](assets/annotate_1.png) +![Title](assets/cover.png) ## 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 ![Title](assets/annotat_3.png) @@ -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) {
Opacity: 100%
+ +
+
Tools
+
+ + + + + + + + + + + + + +
+
+ +
+
Screenshot Post-it
+
+ + + + + + + +
+
-
- -
- - -
-
IAMCCS_annotate · draw & note on ComfyUI
+
+ +
+ + +
+
IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0
www.carminecristalloscalzi.com
`; container.appendChild(section); @@ -359,12 +2713,35 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const hidpiChk = section.querySelector('#iam_hidpi'); const hiddenChk = section.querySelector('#iam_hidden'); const penOnlyChk = section.querySelector('#iam_penonly'); + const pinModeChk = section.querySelector('#iam_pinmode'); + const selOvrChk = section.querySelector('#iam_sel_override'); + const toolDrawBtn = section.querySelector('#iam_tool_draw'); + const toolSelectBtn = section.querySelector('#iam_tool_select'); + const toolTransformBtn = section.querySelector('#iam_tool_transform'); + const toolRotateBtn = section.querySelector('#iam_tool_rotate'); + const toolShotBtn = section.querySelector('#iam_tool_shot'); + const selMode = section.querySelector('#iam_select_mode'); + const tfMode = section.querySelector('#iam_transform_mode'); + const selClearBtn = section.querySelector('#iam_sel_clear'); + const selCopyBtn = section.querySelector('#iam_sel_copy'); + const selCutBtn = section.querySelector('#iam_sel_cut'); + const selPasteBtn = section.querySelector('#iam_sel_paste'); + const undoBtn = section.querySelector('#iam_undo'); + const redoBtn = section.querySelector('#iam_redo'); + const stColor = section.querySelector('#iam_sticker_color'); + const stPad = section.querySelector('#iam_sticker_pad'); + const stBorder = section.querySelector('#iam_sticker_border'); + const stShadow = section.querySelector('#iam_sticker_shadow'); + const stShadowStrength = section.querySelector('#iam_sticker_shadow_strength'); + const stClearBtn = section.querySelector('#iam_sticker_clear'); + const stPurgeBtn = section.querySelector('#iam_sticker_purge'); const saveChk = section.querySelector('#iam_save_with_wf'); const saveNow = section.querySelector('#iam_save_now'); const loadNow = section.querySelector('#iam_load_now'); ui.btn.addEventListener('click', () => { setEnabled(!state.enabled); + try { __iamccsFlashCornerBadge(ui.btn, 'done', state.enabled ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); } catch {} }); clr.addEventListener('click', () => { // Clear all non-locked layers @@ -374,24 +2751,39 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { for (const lyr of state.layers) for (const p of lyr.paths) state.paths.push(p); app?.canvas?.setDirty(true, true); console.log('[IAMCCS] Cleared (unlocked layers)'); - if (state.saveWithWorkflow) persistToGraphExtra(true); else removeFromGraphExtra(); + // Always persist locally; optionally also keep workflow.extra clean when disabled. + persistToGraphExtra(true); + if (!state.saveWithWorkflow) removeFromGraphExtra(); + try { __iamccsFlashCornerBadge(clr, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Annotations cleared (unlocked layers)', { kind: 'info', ms: 1200 }); } catch {} }); color.addEventListener('input', () => { state.color = color.value; + schedulePaletteAutoHide(color); + try { saveStateToLayerStyle(); } catch {} + try { + const didText = syncTextLayerFromStateColor(); + if (!didText) persistToGraphExtra(); + } catch { try { persistToGraphExtra(); } catch {} } }); width.addEventListener('input', () => { state.width = parseInt(width.value, 10) || 3; if (state.eraser) state.widthErase = state.width; else state.widthDraw = state.width; if (ui.widthValue) ui.widthValue.textContent = String(state.width); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); opacity.addEventListener('input', () => { const pct = parseInt(opacity.value, 10) || 100; state.opacity = Math.max(0.1, Math.min(1, pct / 100)); if (state.eraser) state.opacityErase = state.opacity; else state.opacityDraw = state.opacity; if (ui.opacityValue) ui.opacityValue.textContent = String(Math.round(state.opacity * 100)); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); ui.eraserBtn.addEventListener('click', () => { setEraserMode(!state.eraser); + try { showToast(state.eraser ? 'Eraser enabled' : 'Eraser disabled', { kind: 'info', ms: 1100 }); } catch {} + try { __iamccsFlashCornerBadge(ui.eraserBtn, 'done', state.eraser ? 'ER' : 'DR', { ms: 800, bg: '#ff9800' }); } catch {} + try { syncToolsUI(); } catch {} }); ui.constantChk.addEventListener('change', () => { state.constantScreen = !!ui.constantChk.checked; @@ -399,6 +2791,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }); dashedChk.addEventListener('change', () => { state.dashed = !!dashedChk.checked; + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} app?.canvas?.setDirty(true, true); }); hidpiChk.addEventListener('change', () => { @@ -407,10 +2800,115 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }); hiddenChk.addEventListener('change', () => { state.hidden = !!hiddenChk.checked; + try { showToast(state.hidden ? 'Hide notes enabled' : 'Hide notes disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} app?.canvas?.setDirty(true, true); }); penOnlyChk.addEventListener('change', () => { state.penOnly = !!penOnlyChk.checked; + try { showToast(state.penOnly ? 'Pen only enabled' : 'Pen only disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + + pinModeChk?.addEventListener('change', () => { + state.pinMode = !!pinModeChk.checked; + try { showToast(state.pinMode ? 'Pin mode enabled' : 'Pin mode disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + + selOvrChk?.addEventListener('change', () => { + state.selOverride = !!selOvrChk.checked; + // If Annotate is ON, enabling SEL means the user expects to select, not draw. + if (state.enabled && state.selOverride) { + try { setTool('select'); } catch {} + } + try { showToast(state.selOverride ? 'Selection mode enabled' : 'Selection mode disabled', { kind: 'info', ms: 1200 }); } catch {} + if (!state.selOverride && !state.enabled) { + try { clearSelection(); } catch {} + } + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + + const setToolAutoEnable = (t) => { + try { + if (!state.enabled) setEnabled(true); + } catch {} + setTool(t); + }; + + toolDrawBtn?.addEventListener('click', () => { setToolAutoEnable('draw'); try { __iamccsFlashCornerBadge(toolDrawBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Draw enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolSelectBtn?.addEventListener('click', () => { setToolAutoEnable('select'); try { __iamccsFlashCornerBadge(toolSelectBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Select enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolTransformBtn?.addEventListener('click', () => { setToolAutoEnable('transform'); try { __iamccsFlashCornerBadge(toolTransformBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Transform enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolRotateBtn?.addEventListener('click', () => { setToolAutoEnable('rotate'); try { __iamccsFlashCornerBadge(toolRotateBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Rotate enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolShotBtn?.addEventListener('click', () => { setToolAutoEnable('screenshot'); try { __iamccsFlashCornerBadge(toolShotBtn, 'done', 'ON', { ms: 750, bg: '#ff9800' }); } catch {} try { showToast('Screenshot tool enabled', { kind: 'info', ms: 1000 }); } catch {} }); + selMode?.addEventListener('change', () => { setSelectMode(selMode.value); try { showToast(`Selection shape: ${String(selMode.value || 'rect')}`, { kind: 'info', ms: 1000 }); } catch {} }); + tfMode?.addEventListener('change', () => { setTransformMode(tfMode.value); try { showToast(`Transform mode: ${String(tfMode.value || 'fixed')}`, { kind: 'info', ms: 1000 }); } catch {} }); + selClearBtn?.addEventListener('click', () => { clearSelection(); try { __iamccsFlashCornerBadge(selClearBtn, 'done', 'OK', { ms: 800, bg: '#ff9800' }); } catch {} try { showToast('Selection cleared', { kind: 'info', ms: 900 }); } catch {} }); + selCopyBtn?.addEventListener('click', () => { const ok = copySelectionToClipboard(); try { __iamccsFlashCornerBadge(selCopyBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Selection copied' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + selCutBtn?.addEventListener('click', () => { + const ok = copySelectionToClipboard({ clearAfter: false }); + if (ok) deleteSelection({ allowLocked: false }); + try { __iamccsFlashCornerBadge(selCutBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Selection cut' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} + }); + selPasteBtn?.addEventListener('click', () => { const ok = pasteClipboardAt(state.lastPointerGraphPos); try { __iamccsFlashCornerBadge(selPasteBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Pasted' : 'Clipboard empty', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + + undoBtn?.addEventListener('click', () => { + const ok = doUndo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(undoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Undo' : 'Nothing to undo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + redoBtn?.addEventListener('click', () => { + const ok = doRedo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(redoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Redo' : 'Nothing to redo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + + stColor?.addEventListener('input', () => { + state.stickerFrameColor = stColor.value; + schedulePaletteAutoHide(stColor); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stPad?.addEventListener('input', () => { + state.stickerPaddingPx = parseInt(stPad.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stBorder?.addEventListener('input', () => { + state.stickerBorderWidthPx = parseInt(stBorder.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadow?.addEventListener('change', () => { + state.stickerShadow = !!stShadow.checked; + syncStickerUI(); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadowStrength?.addEventListener('input', () => { + state.stickerShadowStrength = parseInt(stShadowStrength.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stClearBtn?.addEventListener('click', () => { + state.stickers = []; + clearSelection(); + app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(stClearBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Post-its cleared', { kind: 'info', ms: 1100 }); } catch {} + }); + + stPurgeBtn?.addEventListener('click', async () => { + await purgeOldStickerScreenshotCacheForCurrentWorkflow(); + try { __iamccsFlashCornerBadge(stPurgeBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Old screenshot cache purged', { kind: 'info', ms: 1200 }); } catch {} }); saveChk?.addEventListener('change', () => { state.saveWithWorkflow = !!saveChk.checked; @@ -418,10 +2916,14 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }); saveNow?.addEventListener('click', () => { persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(saveNow, 'done', 'OK', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast('Saved into workflow', { kind: 'info', ms: 1000 }); } catch {} }); loadNow?.addEventListener('click', () => { const loaded = loadFromGraphExtra(); if (!loaded) console.warn('[IAMCCS] Nessuna annotazione trovata in workflow.extra'); + try { __iamccsFlashCornerBadge(loadNow, 'done', loaded ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast(loaded ? 'Loaded from workflow' : 'No annotations found in workflow', { kind: loaded ? 'info' : 'warn', ms: 1400 }); } catch {} }); // If we mounted to body (fallback), try to re-parent to real sidebar when it appears @@ -443,6 +2945,8 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Initial UI state syncUI(); syncBrushOpacityUI(); + syncToolsUI(); + syncStickerUI(); return section; } @@ -477,6 +2981,52 @@ if (!window.IAMCCS_ANNOTATE_LOADED) {
Opacity: 100%
+ +
+
Tools
+
+ + + + + + + + + + + + +
+
+ +
+
Screenshot Post-it
+
+ + + + + + + +
+
@@ -510,7 +3067,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { -
IAMCCS_annotate · draw & note on ComfyUI
+
IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0
www.carminecristalloscalzi.com
`; // Wire events (same as sidebar) ui.btn = panel.querySelector('#iam_toggle'); @@ -526,11 +3083,35 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const hidpiChk = panel.querySelector('#iam_hidpi'); const hiddenChk = panel.querySelector('#iam_hidden'); const penOnlyChk = panel.querySelector('#iam_penonly'); + const selOvrChk = panel.querySelector('#iam_sel_override'); + 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 undoBtn = panel.querySelector('#iam_undo'); + const redoBtn = panel.querySelector('#iam_redo'); + const stColor = panel.querySelector('#iam_sticker_color'); + const stPad = panel.querySelector('#iam_sticker_pad'); + const stBorder = panel.querySelector('#iam_sticker_border'); + const stShadow = panel.querySelector('#iam_sticker_shadow'); + const stShadowStrength = panel.querySelector('#iam_sticker_shadow_strength'); + const stClearBtn = panel.querySelector('#iam_sticker_clear'); + const stPurgeBtn = panel.querySelector('#iam_sticker_purge'); const saveChk = panel.querySelector('#iam_save_with_wf'); const saveNow = panel.querySelector('#iam_save_now'); const loadNow = panel.querySelector('#iam_load_now'); - ui.btn.addEventListener('click', () => setEnabled(!state.enabled)); + ui.btn.addEventListener('click', () => { + setEnabled(!state.enabled); + try { __iamccsFlashCornerBadge(ui.btn, 'done', state.enabled ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); } catch {} + }); clr.addEventListener('click', () => { // Clear all non-locked layers for (const lyr of state.layers) if (!lyr.locked) lyr.paths = []; @@ -539,22 +3120,38 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { for (const lyr of state.layers) for (const p of lyr.paths) state.paths.push(p); app?.canvas?.setDirty(true, true); console.log('[IAMCCS] Cleared (unlocked layers)'); - if (state.saveWithWorkflow) persistToGraphExtra(true); else removeFromGraphExtra(); + persistToGraphExtra(true); + if (!state.saveWithWorkflow) removeFromGraphExtra(); + try { __iamccsFlashCornerBadge(clr, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Annotations cleared (unlocked layers)', { kind: 'info', ms: 1200 }); } catch {} + }); + color.addEventListener('input', () => { + state.color = color.value; + try { schedulePaletteAutoHide(color); } catch {} + try { saveStateToLayerStyle(); } catch {} + try { + const didText = syncTextLayerFromStateColor(); + if (!didText) persistToGraphExtra(); + } catch { try { persistToGraphExtra(); } catch {} } }); - color.addEventListener('input', () => { state.color = color.value; }); width.addEventListener('input', () => { state.width = parseInt(width.value, 10) || 3; if (state.eraser) state.widthErase = state.width; else state.widthDraw = state.width; if (ui.widthValue) ui.widthValue.textContent = String(state.width); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); opacity.addEventListener('input', () => { const pct = parseInt(opacity.value, 10) || 100; state.opacity = Math.max(0.1, Math.min(1, pct / 100)); if (state.eraser) state.opacityErase = state.opacity; else state.opacityDraw = state.opacity; if (ui.opacityValue) ui.opacityValue.textContent = String(Math.round(state.opacity * 100)); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); ui.eraserBtn.addEventListener('click', () => { setEraserMode(!state.eraser); + try { showToast(state.eraser ? 'Eraser enabled' : 'Eraser disabled', { kind: 'info', ms: 1100 }); } catch {} + try { __iamccsFlashCornerBadge(ui.eraserBtn, 'done', state.eraser ? 'ER' : 'DR', { ms: 800, bg: '#ff9800' }); } catch {} + try { syncToolsUI(); } catch {} }); ui.constantChk.addEventListener('change', () => { state.constantScreen = !!ui.constantChk.checked; @@ -562,6 +3159,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }); dashedChk.addEventListener('change', () => { state.dashed = !!dashedChk.checked; + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} app?.canvas?.setDirty(true, true); }); hidpiChk.addEventListener('change', () => { @@ -570,19 +3168,122 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }); hiddenChk.addEventListener('change', () => { state.hidden = !!hiddenChk.checked; + try { showToast(state.hidden ? 'Hide notes enabled' : 'Hide notes disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} app?.canvas?.setDirty(true, true); }); penOnlyChk.addEventListener('change', () => { state.penOnly = !!penOnlyChk.checked; + try { showToast(state.penOnly ? 'Pen only enabled' : 'Pen only disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + + selOvrChk?.addEventListener('change', () => { + state.selOverride = !!selOvrChk.checked; + // If Annotate is ON, enabling SEL means the user expects to select, not draw. + if (state.enabled && state.selOverride) { + try { setTool('select'); } catch {} + } + try { showToast(state.selOverride ? 'Selection mode enabled' : 'Selection mode disabled', { kind: 'info', ms: 1200 }); } catch {} + if (!state.selOverride && !state.enabled) { + try { clearSelection(); } catch {} + } + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + + const setToolAutoEnable = (t) => { + try { + if (!state.enabled) setEnabled(true); + } catch {} + setTool(t); + }; + + toolDrawBtn?.addEventListener('click', () => { setToolAutoEnable('draw'); try { __iamccsFlashCornerBadge(toolDrawBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Draw enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolSelectBtn?.addEventListener('click', () => { setToolAutoEnable('select'); try { __iamccsFlashCornerBadge(toolSelectBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Select enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolTransformBtn?.addEventListener('click', () => { setToolAutoEnable('transform'); try { __iamccsFlashCornerBadge(toolTransformBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Transform enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolRotateBtn?.addEventListener('click', () => { setToolAutoEnable('rotate'); try { __iamccsFlashCornerBadge(toolRotateBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Rotate enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolShotBtn?.addEventListener('click', () => { setToolAutoEnable('screenshot'); try { __iamccsFlashCornerBadge(toolShotBtn, 'done', 'ON', { ms: 750, bg: '#ff9800' }); } catch {} try { showToast('Screenshot tool enabled', { kind: 'info', ms: 1000 }); } catch {} }); + selMode?.addEventListener('change', () => { setSelectMode(selMode.value); try { showToast(`Selection shape: ${String(selMode.value || 'rect')}`, { kind: 'info', ms: 1000 }); } catch {} }); + tfMode?.addEventListener('change', () => { setTransformMode(tfMode.value); try { showToast(`Transform mode: ${String(tfMode.value || 'fixed')}`, { kind: 'info', ms: 1000 }); } catch {} }); + selClearBtn?.addEventListener('click', () => { clearSelection(); try { __iamccsFlashCornerBadge(selClearBtn, 'done', 'OK', { ms: 800, bg: '#ff9800' }); } catch {} try { showToast('Selection cleared', { kind: 'info', ms: 900 }); } catch {} }); + selCopyBtn?.addEventListener('click', () => { const ok = copySelectionToClipboard(); try { __iamccsFlashCornerBadge(selCopyBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Selection copied' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + selCutBtn?.addEventListener('click', () => { + const ok = copySelectionToClipboard({ clearAfter: false }); + if (ok) deleteSelection({ allowLocked: false }); + try { __iamccsFlashCornerBadge(selCutBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Selection cut' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} + }); + selPasteBtn?.addEventListener('click', () => { const ok = pasteClipboardAt(state.lastPointerGraphPos); try { __iamccsFlashCornerBadge(selPasteBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Pasted' : 'Clipboard empty', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + + undoBtn?.addEventListener('click', () => { + const ok = doUndo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(undoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Undo' : 'Nothing to undo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + redoBtn?.addEventListener('click', () => { + const ok = doRedo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(redoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Redo' : 'Nothing to redo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + + stColor?.addEventListener('input', () => { + state.stickerFrameColor = stColor.value; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stPad?.addEventListener('input', () => { + state.stickerPaddingPx = parseInt(stPad.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stBorder?.addEventListener('input', () => { + state.stickerBorderWidthPx = parseInt(stBorder.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadow?.addEventListener('change', () => { + state.stickerShadow = !!stShadow.checked; + syncStickerUI(); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadowStrength?.addEventListener('input', () => { + state.stickerShadowStrength = parseInt(stShadowStrength.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stClearBtn?.addEventListener('click', () => { + state.stickers = []; + clearSelection(); + app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(stClearBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Post-its cleared', { kind: 'info', ms: 1100 }); } catch {} + }); + + stPurgeBtn?.addEventListener('click', async () => { + await purgeOldStickerScreenshotCacheForCurrentWorkflow(); + try { __iamccsFlashCornerBadge(stPurgeBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Old screenshot cache purged', { kind: 'info', ms: 1200 }); } catch {} }); saveChk?.addEventListener('change', () => { state.saveWithWorkflow = !!saveChk.checked; if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); }); - saveNow?.addEventListener('click', () => persistToGraphExtra(true)); + saveNow?.addEventListener('click', () => { + persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(saveNow, 'done', 'OK', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast('Saved into workflow', { kind: 'info', ms: 1000 }); } catch {} + }); loadNow?.addEventListener('click', () => { const loaded = loadFromGraphExtra(); if (!loaded) console.warn('[IAMCCS] Nessuna annotazione trovata in workflow.extra'); + try { __iamccsFlashCornerBadge(loadNow, 'done', loaded ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast(loaded ? 'Loaded from workflow' : 'No annotations found in workflow', { kind: loaded ? 'info' : 'warn', ms: 1400 }); } catch {} }); } @@ -607,6 +3308,8 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { makeDraggable(panel, { storageKey: 'iamccs_dock_pos', handle: header, isFixed: false }); syncUI(); syncBrushOpacityUI(); + syncToolsUI(); + syncStickerUI(); // Hide floating toggle if dock is visible if (ui.floating) ui.floating.style.display = 'none'; return panel; @@ -615,7 +3318,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { function ensureStateHydratedFromExisting() { try { if (state.hydrated) return; - const existing = app?.graph?.extra?.iamccs_annotations; + const existing = readAnnotationsFromExtra(getActiveGraphKey()); if (!existing) return; // Build merged layers from existing let mergedLayers = []; @@ -671,6 +3374,59 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Flatten paths for backward compatibility state.paths = []; for (const lyr of state.layers) for (const p of lyr.paths) state.paths.push(p); + + // Stickers + sticker style (hydrate once, without clobbering new in-session stickers) + if (Array.isArray(existing.stickers)) { + const have = new Set((state.stickers || []).map(s => s?.id).filter(Boolean)); + const incoming = existing.stickers.map(s => { + const id = s.id || newStickerId(); + const kind = (s.kind === 'text') ? 'text' + : (s.kind === 'image') ? 'image' + : ((typeof s.text === 'string' && !s.dataUrl && !s.dataKey) ? 'text' : 'image'); + const dataUrl = (typeof s.dataUrl === 'string' && s.dataUrl) ? s.dataUrl : null; + const dataKey = (typeof s.dataKey === 'string' && s.dataKey) ? s.dataKey : ((kind === 'image') ? stickerDataKeyForId(id) : undefined); + if (kind === 'image' && dataUrl && dataKey) { + try { __idbSet(__IAMCCS_IDB_STORE_STICKERS, dataKey, dataUrl); } catch {} + } + return { + id, + x: typeof s.x === 'number' ? s.x : 0, + y: typeof s.y === 'number' ? s.y : 0, + w: typeof s.w === 'number' ? s.w : 120, + h: typeof s.h === 'number' ? s.h : 120, + rot: typeof s.rot === 'number' ? s.rot : 0, + kind, + pinned: !!s.pinned, + parentStickerId: (typeof s.parentStickerId === 'string' && s.parentStickerId) ? s.parentStickerId : undefined, + dataUrl, + dataKey, + text: (typeof s.text === 'string') ? s.text : '', + }; + }).filter(s => (s.kind === 'text') ? true : (!!s.dataUrl || !!s.dataKey)); + if (!Array.isArray(state.stickers) || !state.stickers.length) { + state.stickers = incoming; + } else { + for (const st of incoming) { + if (!have.has(st.id)) state.stickers.push(st); + } + } + + // Lazy-load any missing screenshot blobs from IndexedDB + try { + for (const st of (state.stickers || [])) { + if (!st || isTextSticker(st)) continue; + if (!st.dataUrl && st.dataKey) requestHydrateStickerData(st.id, st.dataKey); + } + } catch {} + } + if (existing.stickerStyle && typeof existing.stickerStyle === 'object') { + if (typeof existing.stickerStyle.frameColor === 'string') state.stickerFrameColor = existing.stickerStyle.frameColor; + if (typeof existing.stickerStyle.paddingPx === 'number') state.stickerPaddingPx = existing.stickerStyle.paddingPx; + if (typeof existing.stickerStyle.borderWidthPx === 'number') state.stickerBorderWidthPx = existing.stickerStyle.borderWidthPx; + if (typeof existing.stickerStyle.shadow === 'boolean') state.stickerShadow = existing.stickerStyle.shadow; + if (typeof existing.stickerStyle.shadowStrength === 'number') state.stickerShadowStrength = existing.stickerStyle.shadowStrength; + } + // Clamp current layer index if (state.currentLayerIdx >= state.layers.length) state.currentLayerIdx = Math.max(0, state.layers.length - 1); state.hydrated = true; @@ -681,38 +3437,70 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { function persistToGraphExtra(force = false) { if (!app?.graph) return; - if (!state.saveWithWorkflow && !force) return; // Merge existing annotations if we haven't hydrated yet (prevents overwriting) ensureStateHydratedFromExisting(); app.graph.extra = app.graph.extra || {}; - app.graph.extra.iamccs_annotations = { - version: 2, - color: state.color, - width: state.width, - paths: state.paths, - layers: state.layers, - currentLayerIdx: state.currentLayerIdx, - }; + const key = getActiveGraphKey(); + state._activeGraphKey = key; + // Keep a stable workflow signature for autosave (survive refresh) + try { if (!state._workflowSig) state._workflowSig = computeWorkflowSignature(); } catch {} + const payload = buildAnnotationsPayload(); + + // Always autosave locally so deletes/modifications survive refresh. + // Debounced to avoid spamming localStorage during drags. + scheduleLocalAutosave(payload, { immediate: !!force }); + + if (!state.saveWithWorkflow) return; + writeAnnotationsToExtra(key, payload); console.log('[IAMCCS] Annotazioni salvate in workflow.extra'); } - function removeFromGraphExtra() { - if (!app?.graph?.extra) return; - delete app.graph.extra.iamccs_annotations; - console.log('[IAMCCS] Annotazioni rimosse da workflow.extra'); - } - - function loadFromGraphExtra() { - const data = app?.graph?.extra?.iamccs_annotations; + function applyAnnotationsData(data) { if (!data) return false; - state.color = data.color || state.color; - state.width = data.width || state.width; + // Restore per-workflow options (backward compatible: only apply if present) + // NOTE: Annotate must always start DISABLED when opening a workflow. + // We intentionally ignore any persisted `enabled` value. + if (typeof data.color === 'string') state.color = data.color; + if (typeof data.width === 'number') state.width = data.width; + if (typeof data.opacity === 'number') state.opacity = data.opacity; + if (typeof data.eraser === 'boolean') state.eraser = data.eraser; + if (typeof data.constantScreen === 'boolean') state.constantScreen = data.constantScreen; + if (typeof data.dashed === 'boolean') state.dashed = data.dashed; + if (typeof data.hidden === 'boolean') state.hidden = data.hidden; + if (typeof data.penOnly === 'boolean') state.penOnly = data.penOnly; + if (typeof data.hiDPIx2 === 'boolean') state.hiDPIx2 = data.hiDPIx2; + if (typeof data.selOverride === 'boolean') state.selOverride = data.selOverride; + if (typeof data.pinMode === 'boolean') state.pinMode = data.pinMode; + + if (typeof data.widthDraw === 'number') state.widthDraw = data.widthDraw; + if (typeof data.widthErase === 'number') state.widthErase = data.widthErase; + if (typeof data.opacityDraw === 'number') state.opacityDraw = data.opacityDraw; + if (typeof data.opacityErase === 'number') state.opacityErase = data.opacityErase; + + if (typeof data.tool === 'string') { + try { setTool(data.tool); } catch { state.tool = data.tool; } + } + if (typeof data.selectMode === 'string') { + try { setSelectMode(data.selectMode); } catch { state.selectMode = data.selectMode; } + } + if (typeof data.transformMode === 'string') { + try { setTransformMode(data.transformMode); } catch { state.transformMode = data.transformMode; } + } + + if (typeof data.textFontFamily === 'string') state.textFontFamily = data.textFontFamily; + if (typeof data.textFontSize === 'number') state.textFontSize = data.textFontSize; + if (typeof data.textColor === 'string') state.textColor = data.textColor; + if (typeof data.textFontWeight === 'string') state.textFontWeight = data.textFontWeight; + if (typeof data.textFontStyle === 'string') state.textFontStyle = data.textFontStyle; + if (typeof data.textUnderline === 'boolean') state.textUnderline = data.textUnderline; // Load layers if available (v2), otherwise load paths (v1) if (Array.isArray(data.layers) && data.layers.length > 0) { state.layers = data.layers.map(layer => ({ name: layer.name || 'Layer', + kind: (layer.kind === 'text') ? 'text' : 'draw', + textStickerId: (typeof layer.textStickerId === 'string') ? layer.textStickerId : undefined, visible: layer.visible !== false, locked: !!layer.locked, paths: Array.isArray(layer.paths) ? layer.paths.map(p => ({ @@ -753,17 +3541,222 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } else { return false; } + + // Stickers + sticker style + // NOTE: screenshots can be huge; we keep them in IndexedDB and persist only a small dataKey. + let __needsShrink = false; + if (Array.isArray(data.stickers)) { + state.stickers = data.stickers.map(s => { + const id = s.id || newStickerId(); + const kind = (s.kind === 'text') ? 'text' + : (s.kind === 'image') ? 'image' + : ((typeof s.text === 'string' && !s.dataUrl && !s.dataKey) ? 'text' : 'image'); + const dataUrl = (typeof s.dataUrl === 'string' && s.dataUrl) ? s.dataUrl : null; + const dataKey = (typeof s.dataKey === 'string' && s.dataKey) ? s.dataKey : ((kind === 'image') ? stickerDataKeyForId(id) : undefined); + if (kind === 'image' && dataUrl && dataKey) { + __needsShrink = true; + try { __idbSet(__IAMCCS_IDB_STORE_STICKERS, dataKey, dataUrl); } catch {} + } + return { + id, + x: typeof s.x === 'number' ? s.x : 0, + y: typeof s.y === 'number' ? s.y : 0, + w: typeof s.w === 'number' ? s.w : 120, + h: typeof s.h === 'number' ? s.h : 120, + rot: typeof s.rot === 'number' ? s.rot : 0, + kind, + pinned: !!s.pinned, + parentStickerId: (typeof s.parentStickerId === 'string' && s.parentStickerId) ? s.parentStickerId : undefined, + dataUrl, + dataKey, + text: (typeof s.text === 'string') ? s.text : '', + fontFamily: (typeof s.fontFamily === 'string') ? s.fontFamily : (state.textFontFamily || 'Arial'), + fontSize: (typeof s.fontSize === 'number') ? s.fontSize : (Number(state.textFontSize) || 28), + textColor: (typeof s.textColor === 'string') ? s.textColor : (state.textColor || '#111111'), + fontWeight: (typeof s.fontWeight === 'string') ? s.fontWeight : undefined, + fontStyle: (typeof s.fontStyle === 'string') ? s.fontStyle : undefined, + underline: (typeof s.underline === 'boolean') ? s.underline : undefined, + }; + }).filter(s => (s.kind === 'text') ? true : (!!s.dataUrl || !!s.dataKey)); + } else { + state.stickers = state.stickers || []; + } + if (data.stickerStyle && typeof data.stickerStyle === 'object') { + if (typeof data.stickerStyle.frameColor === 'string') state.stickerFrameColor = data.stickerStyle.frameColor; + if (typeof data.stickerStyle.paddingPx === 'number') state.stickerPaddingPx = data.stickerStyle.paddingPx; + if (typeof data.stickerStyle.borderWidthPx === 'number') state.stickerBorderWidthPx = data.stickerStyle.borderWidthPx; + if (typeof data.stickerStyle.shadow === 'boolean') state.stickerShadow = data.stickerStyle.shadow; + if (typeof data.stickerStyle.shadowStrength === 'number') state.stickerShadowStrength = data.stickerStyle.shadowStrength; + } + // Refresh flat paths from layers to keep v1 compatibility state.paths = []; for (const lyr of state.layers) for (const p of lyr.paths) state.paths.push(p); state.hydrated = true; + + // If legacy workflows embedded screenshots directly, immediately shrink the workflow payload + // (data moved to IndexedDB) to avoid ComfyUI workflow persistence quota errors. + try { + if (__needsShrink && state.saveWithWorkflow) { + persistToGraphExtra(true); + } + } catch {} + + // Lazy-load any screenshot blobs that weren't embedded + try { + for (const st of (state.stickers || [])) { + if (!st || isTextSticker(st)) continue; + if (!st.dataUrl && st.dataKey) requestHydrateStickerData(st.id, st.dataKey); + } + } catch {} + app?.canvas?.setDirty(true, true); - console.log('[IAMCCS] Annotazioni caricate da workflow.extra'); + console.log('[IAMCCS] Annotazioni caricate'); // Apply style of selected layer to current brush try { applyLayerStyleToState(); } catch {} + try { syncStickerUI(); } catch {} + try { syncToolsUI(); } catch {} + try { syncBrushOpacityUI(); } catch {} + try { syncFlagsUI(); } catch {} + try { syncUI(); } catch {} return true; } + function buildAnnotationsPayload() { + return { + version: 2, + enabled: !!state.enabled, + color: state.color, + width: state.width, + // Per-workflow options (so switching workflows does not leak UI/options) + opacity: state.opacity, + eraser: !!state.eraser, + constantScreen: !!state.constantScreen, + dashed: !!state.dashed, + hidden: !!state.hidden, + penOnly: !!state.penOnly, + hiDPIx2: !!state.hiDPIx2, + selOverride: !!state.selOverride, + pinMode: !!state.pinMode, + tool: state.tool, + selectMode: state.selectMode, + transformMode: state.transformMode, + widthDraw: state.widthDraw, + widthErase: state.widthErase, + opacityDraw: state.opacityDraw, + opacityErase: state.opacityErase, + textFontFamily: state.textFontFamily, + textFontSize: state.textFontSize, + textColor: state.textColor, + textFontWeight: state.textFontWeight, + textFontStyle: state.textFontStyle, + textUnderline: !!state.textUnderline, + paths: state.paths, + layers: state.layers, + currentLayerIdx: state.currentLayerIdx, + stickers: Array.isArray(state.stickers) ? state.stickers.map(s => ({ + id: s.id, + x: s.x, + y: s.y, + w: s.w, + h: s.h, + rot: typeof s.rot === 'number' ? s.rot : 0, + kind: (s.kind === 'text') ? 'text' + : (s.kind === 'image') ? 'image' + : ((typeof s.text === 'string' && !s.dataUrl && !s.dataKey) ? 'text' : 'image'), + pinned: !!s.pinned, + parentStickerId: (typeof s.parentStickerId === 'string' && s.parentStickerId) ? s.parentStickerId : undefined, + // For screenshots, persist only a key and store data in IndexedDB to avoid quota issues. + dataKey: (() => { + try { + const kind = (s.kind === 'text') ? 'text' + : (s.kind === 'image') ? 'image' + : ((typeof s.text === 'string' && !s.dataUrl && !s.dataKey) ? 'text' : 'image'); + if (kind !== 'image') return undefined; + const dk = (typeof s.dataKey === 'string' && s.dataKey) ? s.dataKey : stickerDataKeyForId(s.id); + if (dk && s.dataUrl) { + try { __idbSet(__IAMCCS_IDB_STORE_STICKERS, dk, s.dataUrl); } catch {} + } + return dk || undefined; + } catch { + return undefined; + } + })(), + text: (typeof s.text === 'string') ? s.text : undefined, + fontFamily: (typeof s.fontFamily === 'string') ? s.fontFamily : undefined, + fontSize: (typeof s.fontSize === 'number') ? s.fontSize : undefined, + textColor: (typeof s.textColor === 'string') ? s.textColor : undefined, + fontWeight: (typeof s.fontWeight === 'string') ? s.fontWeight : undefined, + fontStyle: (typeof s.fontStyle === 'string') ? s.fontStyle : undefined, + underline: (typeof s.underline === 'boolean') ? s.underline : undefined, + })) : [], + stickerStyle: { + frameColor: state.stickerFrameColor, + paddingPx: state.stickerPaddingPx, + borderWidthPx: state.stickerBorderWidthPx, + shadow: !!state.stickerShadow, + shadowStrength: state.stickerShadowStrength, + }, + }; + } + + function removeFromGraphExtra() { + if (!app?.graph?.extra) return; + const key = getActiveGraphKey(); + try { + if (app.graph.extra.iamccs_annotations_multi?.graphs) { + delete app.graph.extra.iamccs_annotations_multi.graphs[key]; + } + } catch {} + delete app.graph.extra.iamccs_annotations; + console.log('[IAMCCS] Annotazioni rimosse da workflow.extra'); + } + + function loadFromGraphExtra() { + const key = getActiveGraphKey(); + state._activeGraphKey = key; + const data = readAnnotationsFromExtra(key); + if (!data) return false; + return applyAnnotationsData(data); + } + + function getActiveGraphKey() { + try { + const g = (app?.canvas?.graph) || app?.graph; + const base = ( + (g && (g._uid ?? g._id ?? g.uid ?? g.id ?? g.name)) ?? + (g === app?.graph ? 'root' : 'sub') + ); + const sub = g && g._subgraph_node ? (g._subgraph_node.id ?? g._subgraph_node.title ?? g._subgraph_node.type) : null; + return String(base) + (sub != null ? `:${String(sub)}` : ''); + } catch { + return 'root'; + } + } + + function readAnnotationsFromExtra(key) { + const extra = app?.graph?.extra; + if (!extra) return null; + const multi = extra.iamccs_annotations_multi; + if (multi && multi.graphs && Object.prototype.hasOwnProperty.call(multi.graphs, key)) { + return multi.graphs[key]; + } + return extra.iamccs_annotations || null; + } + + function writeAnnotationsToExtra(key, payload) { + if (!app?.graph) return; + app.graph.extra = app.graph.extra || {}; + // Keep legacy field for compatibility (current graph only) + app.graph.extra.iamccs_annotations = payload; + // Multi-graph storage + const multi = app.graph.extra.iamccs_annotations_multi || { version: 2, graphs: {} }; + multi.version = 2; + multi.graphs = multi.graphs || {}; + multi.graphs[key] = payload; + app.graph.extra.iamccs_annotations_multi = multi; + } + function exportAnnotations() { try { // Include current in-progress stroke if any (without mutating state) @@ -897,16 +3890,120 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { try { window.IAMCCS_ANNOTATE_MODULE = { _handleWorkflowChange() { - // If the new graph contains annotations, load them; otherwise clear local state - const loaded = loadFromGraphExtra(); + // New graph loaded: reset workflow signature so autosave key matches + try { state._workflowSig = null; } catch {} + // Prevent cross-workflow paste of annotations via the extension clipboard + try { state.clipboard = null; } catch {} + try { state.pendingSelection = null; } catch {} + try { state.activePointerId = null; } catch {} + try { state.undoStack = []; state.redoStack = []; } catch {} + + // Requirement: whenever a workflow is opened/switched, Annotate starts DISABLED. + // Keep per-workflow memory (layers/options) but do not stay enabled across workflows. + try { setEnabled(false); } catch { state.enabled = false; } + // If the workflow was loaded via app.loadGraphData, we can detect whether that JSON + // actually contains annotations. ComfyUI may keep app.graph.extra between loads, + // so we must proactively clear stale annotations to avoid copying between workflows. + try { + const last = window.__IAMCCS_LAST_LOADED_WORKFLOW; + if (last && last.hasIamccsAnnotations === false) { + // Incoming workflow has no annotations -> ensure graph.extra doesn't keep old ones. + try { removeFromGraphExtra(); } catch {} + } else if (last && last.hasIamccsAnnotations === true) { + // Incoming workflow has annotations -> ensure graph.extra reflects the loaded JSON. + try { + app.graph.extra = app.graph.extra || {}; + if (last.iamccs_annotations != null) app.graph.extra.iamccs_annotations = last.iamccs_annotations; + if (last.iamccs_annotations_multi != null) app.graph.extra.iamccs_annotations_multi = last.iamccs_annotations_multi; + } catch {} + } + } catch {} + + // If the new graph contains annotations, load them; otherwise clear local state. + // IMPORTANT: when Save-with-workflow is OFF, do not auto-load from workflow.extra + // (it may contain stale data from a previous session and would resurrect deleted items). + const loaded = state.saveWithWorkflow ? loadFromGraphExtra() : false; if (!loaded) { - // Reset to default state with single empty layer + // Fallback: restore from local autosave (survive refresh) + try { + const key = getActiveGraphKey(); + state._activeGraphKey = key; + // Legacy autosaves (older versions): localStorage-based (sync) + const legacy = readLocalAutosaveLegacy(key); + if (legacy && applyAnnotationsData(legacy)) return; + + // Current autosaves: IndexedDB-based (async) + readLocalAutosaveAsync(key).then((local) => { + try { + if (local && applyAnnotationsData(local)) { + // Enforce disabled state even after async restore + try { setEnabled(false); } catch { state.enabled = false; } + } + } catch {} + }); + } catch {} + // Reset to default state (layers + options) so nothing leaks across workflows + state.enabled = false; + state.tool = 'draw'; + state.selectMode = 'rect'; + state.transformMode = 'fixed'; + state.pinMode = false; + state.selOverride = false; + state.eraser = false; + state.constantScreen = false; + state.dashed = false; + state.hidden = false; + state.penOnly = false; + state.hiDPIx2 = false; + state.activePointerId = null; + + state.color = '#ff4444'; + state.width = 7; + state.opacity = 1.0; + state.widthDraw = 7; + state.widthErase = 48; + state.opacityDraw = 1.0; + state.opacityErase = 1.0; + + state.textFontFamily = 'Arial'; + state.textFontSize = 28; + state.textColor = '#111111'; + state.textFontWeight = 'normal'; + state.textFontStyle = 'normal'; + state.textUnderline = false; + try { closeTextEditor({ commit: true }); } catch {} + state._textControlsOpen = false; + state.paths = []; state.current = null; + state.stickers = []; + state.selection = null; + state.pendingSelection = null; + state.stickerDrag = null; + state.stickerResize = null; + state.transformDrag = null; + state.rotateDrag = null; + + state.stickerFrameColor = '#ffffff'; + state.stickerPaddingPx = 10; + state.stickerBorderWidthPx = 2; + state.stickerShadow = true; + state.stickerShadowStrength = 12; + state.currentLayerIdx = 0; - state.layers = [{ name: 'Layer 1', visible: true, locked: false, paths: [], style: { color: state.color || '#ff4444', dashed: !!state.dashed, widthDraw: state.widthDraw || 7, widthErase: state.widthErase || 48, opacityDraw: (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0), opacityErase: (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0) } }]; + state.layers = [{ name: 'Layer 1', visible: true, locked: false, paths: [], style: { color: '#ff4444', dashed: false, widthDraw: 7, widthErase: 48, opacityDraw: 1.0, opacityErase: 1.0 } }]; + state.hydrated = true; + + try { syncStickerUI(); } catch {} + try { syncToolsUI(); } catch {} + try { syncBrushOpacityUI(); } catch {} + try { syncFlagsUI(); } catch {} + try { syncUI(); } catch {} app?.canvas?.setDirty(true, true); } + + // Enforce disabled state after load/reset as well. + try { setEnabled(false); } catch { state.enabled = false; } } }; } catch {} @@ -922,6 +4019,74 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { if (typeof layer.style.opacityDraw !== 'number') layer.style.opacityDraw = (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0); if (typeof layer.style.opacityErase !== 'number') layer.style.opacityErase = (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0); } + + function saveStateToLayerStyle(layerIdx, { persist = false } = {}) { + const li = (typeof layerIdx === 'number') ? layerIdx : state.currentLayerIdx; + const lyr = state.layers?.[li]; + if (!lyr) return false; + ensureLayerStyle(lyr); + const sty = lyr.style; + // Color + dashed are always stored per-layer + if (typeof state.color === 'string') sty.color = state.color; + sty.dashed = !!state.dashed; + + // Keep draw/erase values in sync (per-layer memory) + if (typeof state.widthDraw === 'number') sty.widthDraw = state.widthDraw; + if (typeof state.widthErase === 'number') sty.widthErase = state.widthErase; + if (typeof state.opacityDraw === 'number') sty.opacityDraw = state.opacityDraw; + if (typeof state.opacityErase === 'number') sty.opacityErase = state.opacityErase; + + // Also reflect the *currently active* mode values (covers keyboard-driven changes) + if (state.eraser) { + if (typeof state.width === 'number') sty.widthErase = state.width; + if (typeof state.opacity === 'number') sty.opacityErase = state.opacity; + } else { + if (typeof state.width === 'number') sty.widthDraw = state.width; + if (typeof state.opacity === 'number') sty.opacityDraw = state.opacity; + } + + if (persist) { + try { persistToGraphExtra(); } catch {} + } + return true; + } + + function syncTextToolStateFromActiveTextLayer() { + try { + const lyr = state.layers?.[state.currentLayerIdx]; + if (!lyr || lyr.kind !== 'text' || !lyr.textStickerId) return false; + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (!st || !isTextSticker(st)) return false; + + 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 (typeof st.textColor === 'string') state.textColor = st.textColor; + + // If context menu is open, update its text controls to reflect the selected text layer + try { + if (ui.contextMenu) { + const textFont = ui.contextMenu.querySelector('#ctx_text_font'); + const textSize = ui.contextMenu.querySelector('#ctx_text_size'); + const textBold = ui.contextMenu.querySelector('#ctx_text_bold'); + const textItalic = ui.contextMenu.querySelector('#ctx_text_italic'); + const textUnderline = ui.contextMenu.querySelector('#ctx_text_underline'); + if (textFont) textFont.value = String(state.textFontFamily || 'Arial'); + if (textSize) textSize.value = String(Number(state.textFontSize) || 28); + if (textBold) textBold.style.background = (state.textFontWeight === 'bold') ? '#fb8c00' : '#37474f'; + if (textItalic) textItalic.style.background = (state.textFontStyle === 'italic') ? '#fb8c00' : '#37474f'; + if (textUnderline) textUnderline.style.background = state.textUnderline ? '#fb8c00' : '#37474f'; + } + } catch {} + + return true; + } catch { + return false; + } + } function getCurrentLayer() { const lyr = state.layers[state.currentLayerIdx]; ensureLayerStyle(lyr); @@ -948,10 +4113,14 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { state.opacityErase = style.opacityErase; syncFlagsUI(); syncBrushOpacityUI(); + syncColorUI(); syncUI(); + try { syncTextToolStateFromActiveTextLayer(); } catch {} app?.canvas?.setDirty(true, true); } function addLayer() { + // Undo snapshot before structural operation + pushHistorySnapshot(); const layerNum = state.layers.length + 1; const base = { ...getCurrentLayerStyle() }; state.layers.push({ name: `Layer ${layerNum}`, visible: true, locked: false, paths: [], style: { @@ -964,24 +4133,91 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } }); state.currentLayerIdx = state.layers.length - 1; app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); return state.layers.length - 1; } function deleteLayer(idx) { - if (state.layers.length <= 1) return false; // Keep at least one layer + if (!Array.isArray(state.layers) || idx < 0 || idx >= state.layers.length) return false; + + const target = state.layers[idx]; + const isTextLayer = (target?.kind === 'text'); + const normalCount = state.layers.reduce((acc, lyr) => acc + ((lyr?.kind === 'text') ? 0 : 1), 0); + + // Keep at least one *normal* (non-text) layer at all times + if (!isTextLayer && normalCount <= 1) return false; + + // Also avoid leaving zero layers in weird states + if (state.layers.length <= 1 && isTextLayer) return false; + + // Undo snapshot before destructive operation + pushHistorySnapshot(); + + try { + const lyr = state.layers[idx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + const sid = lyr.textStickerId; + const si = getStickerIdxById(sid); + if (si >= 0) { + if (state.textEditor?.stickerId === sid) { + try { closeTextEditor({ commit: true }); } catch {} + } + state.stickers.splice(si, 1); + } + } + } catch {} state.layers.splice(idx, 1); if (state.currentLayerIdx >= state.layers.length) state.currentLayerIdx = state.layers.length - 1; + + // Safety: if we ever end up with no normal layer (legacy state), recreate one + try { + const normalCount2 = state.layers.reduce((acc, lyr) => acc + ((lyr?.kind === 'text') ? 0 : 1), 0); + if (normalCount2 <= 0) { + const layerNum = state.layers.length + 1; + const baseColor = state.color || '#ff4444'; + state.layers.push({ + name: `Layer ${layerNum}`, + visible: true, + locked: false, + paths: [], + style: { + color: baseColor, + dashed: !!state.dashed, + widthDraw: state.widthDraw || 7, + widthErase: state.widthErase || 48, + opacityDraw: (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0), + opacityErase: (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0), + } + }); + if (state.currentLayerIdx < 0) state.currentLayerIdx = 0; + } + } catch {} + + // Refresh flat paths so deleted layer strokes don't linger + try { + state.paths = []; + for (const lyr of state.layers) for (const p of (lyr?.paths || [])) state.paths.push(p); + } catch {} + app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); return true; } function toggleLayerVisibility(idx) { + // Undo snapshot before structural operation + pushHistorySnapshot(); if (state.layers[idx]) state.layers[idx].visible = !state.layers[idx].visible; app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); } function toggleLayerLock(idx) { + // Undo snapshot before structural operation + pushHistorySnapshot(); if (state.layers[idx]) state.layers[idx].locked = !state.layers[idx].locked; + persistToGraphExtra(true); } function setCurrentLayer(idx) { if (idx >= 0 && idx < state.layers.length) { + try { saveStateToLayerStyle(state.currentLayerIdx); } catch {} state.currentLayerIdx = idx; applyLayerStyleToState(); } @@ -1014,6 +4250,36 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } catch {} } + function snapFloatingToggleToContextMenu(menu) { + try { + if (!menu || !ui.floating || !document.body.contains(ui.floating)) return; + const btn = ui.floating; + const mr = menu.getBoundingClientRect(); + const br = btn.getBoundingClientRect(); + const gap = 6; + const side = ((mr.left + mr.width / 2) < (window.innerWidth / 2)) ? 'left' : 'right'; + + // Prefer placing the button just above the menu corner; if it doesn't fit, place below. + let top = mr.top - br.height - gap; + if (top < 0) top = mr.bottom + gap; + top = Math.max(0, Math.min(window.innerHeight - br.height, top)); + + let left = (side === 'left') ? mr.left : (mr.right - br.width); + left = Math.max(0, Math.min(window.innerWidth - br.width, left)); + + btn.style.left = Math.round(left) + 'px'; + btn.style.top = Math.round(top) + 'px'; + btn.style.bottom = 'auto'; + savePos('iamccs_float_pos', { left: Math.round(left), top: Math.round(top) }); + + // Re-anchor the menu so it keeps its current screen position relative to the snapped button. + try { + const br2 = btn.getBoundingClientRect(); + ui.contextAnchor = { anchor: btn, dx: mr.left - br2.left, dy: mr.top - br2.top }; + } catch {} + } catch {} + } + function ensureFloatingToggle() { if (ui.floating && document.body.contains(ui.floating)) return ui.floating; const btn = document.createElement('button'); @@ -1044,6 +4310,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { e.preventDefault(); e.stopPropagation(); setEnabled(!state.enabled); + try { __iamccsFlashCornerBadge(btn, 'done', state.enabled ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); } catch {} } }, true); // Right-click toggles context menu (and always closes if currently open) @@ -1058,6 +4325,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { showContextMenu(e.clientX, e.clientY, btn); }, true); document.body.appendChild(btn); + try { __iamccsSetCornerBadge(btn, 'enabled', !!state.enabled, 'ON', { bg: '#ff9800' }); } catch {} ui.floating = btn; // Make draggable (whole button) and move menu with it if open makeDraggable(btn, { @@ -1154,20 +4422,86 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { Pen only + + + +
+
Tools
+
+ + + + + + + + + + + + + +
+
+ +
+
Screenshot Post-it
+
+ + + + + + + +
+
Layers
- +
+ + + + + + + + + + +
-
IAMCCS_annotate · draw & note on ComfyUI
+
IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0
www.carminecristalloscalzi.com
@@ -1186,14 +4520,18 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Render layers with newest at the top of the list (visual order), // but keep indices mapped to the underlying state.layers const order = state.layers.map((_, i) => i).reverse(); + const normalCount = state.layers.reduce((acc, lyr) => acc + ((lyr?.kind === 'text') ? 0 : 1), 0); list.innerHTML = order.map((idx) => { const layer = state.layers[idx]; + const isTextLayer = (layer?.kind === 'text'); + const canDelete = isTextLayer ? (state.layers.length > 1) : (normalCount > 1); return `
- + ${layer.kind === 'text' ? `` : ''} +
`; }).join(''); @@ -1202,14 +4540,91 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { row.addEventListener('click', () => { const idx = parseInt(row.dataset.layerRow, 10); setCurrentLayer(idx); + try { + const nm = state.layers?.[idx]?.name || `Layer ${idx + 1}`; + showToast(`${nm} selected`, { kind: 'info', ms: 900 }); + } catch {} + + // If a Text Layer is selected, ensure text controls are visible and synced + try { + const lyr = state.layers?.[idx]; + if (lyr?.kind === 'text') { + state._textControlsOpen = true; + const tc = menu.querySelector('#ctx_text_controls'); + if (tc) tc.style.display = 'flex'; + if (lyr.textStickerId) { + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (st && isTextSticker(st)) { + state.textFontFamily = String(st.fontFamily || state.textFontFamily || 'Arial'); + state.textFontSize = Number(st.fontSize) || Number(state.textFontSize) || 28; + state.textFontWeight = String(st.fontWeight || state.textFontWeight || 'normal'); + state.textFontStyle = String(st.fontStyle || state.textFontStyle || 'normal'); + state.textUnderline = !!(st.underline ?? state.textUnderline); + } + } + const tf = menu.querySelector('#ctx_text_font'); + const ts = menu.querySelector('#ctx_text_size'); + const tb = menu.querySelector('#ctx_text_bold'); + const ti = menu.querySelector('#ctx_text_italic'); + const tu = menu.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 {} + renderLayersList(); }); + row.addEventListener('dblclick', (e) => { + const idx = parseInt(row.dataset.layerRow, 10); + const lyr = state.layers?.[idx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + e.preventDefault(); + e.stopPropagation(); + try { openTextEditorForStickerId(lyr.textStickerId, app?.canvas); } catch {} + } + }); }); list.querySelectorAll('[data-layer-name]').forEach(inp => { // Single click selects; does not start editing inp.addEventListener('click', (e) => { const idx = parseInt(inp.dataset.layerName, 10); setCurrentLayer(idx); + + // If a Text Layer is selected, ensure text controls are visible and synced + try { + const lyr = state.layers?.[idx]; + if (lyr?.kind === 'text') { + state._textControlsOpen = true; + const tc = menu.querySelector('#ctx_text_controls'); + if (tc) tc.style.display = 'flex'; + if (lyr.textStickerId) { + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (st && isTextSticker(st)) { + state.textFontFamily = String(st.fontFamily || state.textFontFamily || 'Arial'); + state.textFontSize = Number(st.fontSize) || Number(state.textFontSize) || 28; + state.textFontWeight = String(st.fontWeight || state.textFontWeight || 'normal'); + state.textFontStyle = String(st.fontStyle || state.textFontStyle || 'normal'); + state.textUnderline = !!(st.underline ?? state.textUnderline); + } + } + const tf = menu.querySelector('#ctx_text_font'); + const ts = menu.querySelector('#ctx_text_size'); + const tb = menu.querySelector('#ctx_text_bold'); + const ti = menu.querySelector('#ctx_text_italic'); + const tu = menu.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 {} + // Delay re-render slightly so a double-click can cancel it if (nameClickTimer) clearTimeout(nameClickTimer); nameClickTimer = setTimeout(() => { @@ -1255,6 +4670,12 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { e.stopPropagation(); const idx = parseInt(btn.dataset.layerToggleVis, 10); toggleLayerVisibility(idx); + try { + const nm = state.layers?.[idx]?.name || `Layer ${idx + 1}`; + const vis = !!state.layers?.[idx]?.visible; + __iamccsFlashCornerBadge(btn, 'done', vis ? 'ON' : 'OFF', { ms: 850, bg: '#ff9800' }); + showToast(`${nm}: visibility ${vis ? 'enabled' : 'disabled'}`, { kind: 'info', ms: 1100 }); + } catch {} renderLayersList(); }); }); @@ -1263,6 +4684,12 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { e.stopPropagation(); const idx = parseInt(btn.dataset.layerToggleLock, 10); toggleLayerLock(idx); + try { + const nm = state.layers?.[idx]?.name || `Layer ${idx + 1}`; + const locked = !!state.layers?.[idx]?.locked; + __iamccsFlashCornerBadge(btn, 'done', locked ? 'ON' : 'OFF', { ms: 850, bg: '#ff9800' }); + showToast(`${nm}: lock ${locked ? 'enabled' : 'disabled'}`, { kind: 'info', ms: 1100 }); + } catch {} renderLayersList(); }); }); @@ -1270,11 +4697,35 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { btn.addEventListener('click', (e) => { e.stopPropagation(); const idx = parseInt(btn.dataset.layerDelete, 10); - if (deleteLayer(idx)) renderLayersList(); + const nm = state.layers?.[idx]?.name || `Layer ${idx + 1}`; + const ok = deleteLayer(idx); + try { + __iamccsFlashCornerBadge(btn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); + showToast(ok ? `${nm} deleted` : `Cannot delete ${nm}`, { kind: ok ? 'info' : 'warn', ms: 1300 }); + } catch {} + if (ok) renderLayersList(); + }); + }); + + list.querySelectorAll('[data-layer-edit-text]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.layerEditText, 10); + const lyr = state.layers?.[idx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + setCurrentLayer(idx); + try { __iamccsFlashCornerBadge(btn, 'done', 'OK', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast('Text editor opened', { kind: 'info', ms: 1000 }); } catch {} + try { openTextEditorForStickerId(lyr.textStickerId, app?.canvas); } catch {} + renderLayersList(); + } }); }); } + // Expose so other parts (e.g. Text tool creation) can refresh the list + ui.renderLayersList = renderLayersList; + // Initial render renderLayersList(); @@ -1282,11 +4733,191 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const addLayerBtn = menu.querySelector('#ctx_add_layer'); if (addLayerBtn) { addLayerBtn.addEventListener('click', () => { - addLayer(); + const idx = addLayer(); + try { + const nm = state.layers?.[idx]?.name || `Layer ${idx + 1}`; + __iamccsFlashCornerBadge(addLayerBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); + showToast(`${nm} created`, { kind: 'info', ms: 1100 }); + } catch {} renderLayersList(); }); } + // Text tool UI (button + font + size) + const addTextBtn = menu.querySelector('#ctx_add_text'); + const textControls = menu.querySelector('#ctx_text_controls'); + const textFont = menu.querySelector('#ctx_text_font'); + const textSize = menu.querySelector('#ctx_text_size'); + const textNorm = menu.querySelector('#ctx_text_norm'); + const textBold = menu.querySelector('#ctx_text_bold'); + const textItalic = menu.querySelector('#ctx_text_italic'); + const textUnderline = menu.querySelector('#ctx_text_underline'); + try { + if (textFont) { + const fonts = [ + 'Arial', + 'Verdana', + 'Tahoma', + 'Trebuchet MS', + 'Times New Roman', + 'Georgia', + 'Courier', + 'Courier New', + 'Lucida Console', + 'Consolas', + 'Cascadia Mono', + 'Cascadia Code', + 'Roboto Mono', + 'American Typewriter', + 'Typewriter', + 'monospace', + 'Impact', + 'Comic Sans MS', + 'system-ui', + ]; + textFont.innerHTML = fonts.map(f => ``).join(''); + textFont.value = String(state.textFontFamily || 'Arial'); + textFont.addEventListener('change', () => { + state.textFontFamily = String(textFont.value || 'Arial'); + // If editing a text sticker, update textarea style live + try { + if (state.textEditor?.el) state.textEditor.el.style.fontFamily = state.textFontFamily; + } catch {} + + // If current layer is a Text Layer, persist font choice into that text box + try { + const lyr = state.layers?.[state.currentLayerIdx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (st && isTextSticker(st)) { + st.fontFamily = state.textFontFamily; + persistToGraphExtra(true); + app?.canvas?.setDirty(true, true); + } + } + } catch {} + }); + } + if (textSize) { + textSize.value = String(Number(state.textFontSize) || 28); + textSize.addEventListener('input', () => { + const v = Math.max(8, Math.min(200, Number(textSize.value) || 28)); + state.textFontSize = v; + try { + if (state.textEditor?.el) state.textEditor.el.style.fontSize = String(v) + 'px'; + } catch {} + + // If current layer is a Text Layer, persist font size into that text box + try { + const lyr = state.layers?.[state.currentLayerIdx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (st && isTextSticker(st)) { + st.fontSize = v; + persistToGraphExtra(true); + app?.canvas?.setDirty(true, true); + } + } + } catch {} + }); + } + if (textControls) { + textControls.style.display = state._textControlsOpen ? 'flex' : 'none'; + } + + const syncTextStyleBtns = () => { + try { + if (textBold) textBold.style.background = (state.textFontWeight === 'bold') ? '#fb8c00' : '#37474f'; + if (textItalic) textItalic.style.background = (state.textFontStyle === 'italic') ? '#fb8c00' : '#37474f'; + if (textUnderline) textUnderline.style.background = state.textUnderline ? '#fb8c00' : '#37474f'; + } catch {} + }; + + const applyTextStyleToActive = () => { + try { + const lyr = state.layers?.[state.currentLayerIdx]; + if (lyr?.kind === 'text' && lyr.textStickerId) { + const si = getStickerIdxById(lyr.textStickerId); + const st = si >= 0 ? state.stickers?.[si] : null; + if (st && isTextSticker(st)) { + st.fontWeight = String(state.textFontWeight || 'normal'); + st.fontStyle = String(state.textFontStyle || 'normal'); + st.underline = !!state.textUnderline; + if (state.textEditor?.stickerId === st.id && state.textEditor?.el) { + state.textEditor.el.style.fontWeight = st.fontWeight; + state.textEditor.el.style.fontStyle = st.fontStyle; + state.textEditor.el.style.textDecoration = st.underline ? 'underline' : 'none'; + } + persistToGraphExtra(true); + app?.canvas?.setDirty(true, true); + } + } + } catch {} + }; + + textNorm?.addEventListener('click', () => { + state.textFontWeight = 'normal'; + state.textFontStyle = 'normal'; + state.textUnderline = false; + syncTextStyleBtns(); + applyTextStyleToActive(); + try { __iamccsFlashCornerBadge(textNorm, 'done', 'OK', { ms: 750, bg: '#ff9800' }); } catch {} + try { showToast('Text style: normal', { kind: 'info', ms: 950 }); } catch {} + }); + textBold?.addEventListener('click', () => { + state.textFontWeight = (state.textFontWeight === 'bold') ? 'normal' : 'bold'; + syncTextStyleBtns(); + applyTextStyleToActive(); + try { + const on = state.textFontWeight === 'bold'; + __iamccsFlashCornerBadge(textBold, 'done', on ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); + showToast(`Bold ${on ? 'enabled' : 'disabled'}`, { kind: 'info', ms: 950 }); + } catch {} + }); + textItalic?.addEventListener('click', () => { + state.textFontStyle = (state.textFontStyle === 'italic') ? 'normal' : 'italic'; + syncTextStyleBtns(); + applyTextStyleToActive(); + try { + const on = state.textFontStyle === 'italic'; + __iamccsFlashCornerBadge(textItalic, 'done', on ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); + showToast(`Italic ${on ? 'enabled' : 'disabled'}`, { kind: 'info', ms: 950 }); + } catch {} + }); + textUnderline?.addEventListener('click', () => { + state.textUnderline = !state.textUnderline; + syncTextStyleBtns(); + applyTextStyleToActive(); + try { + const on = !!state.textUnderline; + __iamccsFlashCornerBadge(textUnderline, 'done', on ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); + showToast(`Underline ${on ? 'enabled' : 'disabled'}`, { kind: 'info', ms: 950 }); + } catch {} + }); + + syncTextStyleBtns(); + } catch {} + if (addTextBtn) { + addTextBtn.addEventListener('click', () => { + state._textControlsOpen = true; + if (textControls) textControls.style.display = 'flex'; + if (!state.enabled) setEnabled(true); + setTool('text'); + try { __iamccsFlashCornerBadge(addTextBtn, 'done', 'ON', { ms: 750, bg: '#ff9800' }); } catch {} + try { showToast('Text tool enabled', { kind: 'info', ms: 1000 }); } catch {} + try { if (textFont) textFont.value = String(state.textFontFamily || 'Arial'); } catch {} + try { if (textSize) textSize.value = String(Number(state.textFontSize) || 28); } catch {} + try { + if (textBold) textBold.style.background = (state.textFontWeight === 'bold') ? '#fb8c00' : '#37474f'; + if (textItalic) textItalic.style.background = (state.textFontStyle === 'italic') ? '#fb8c00' : '#37474f'; + if (textUnderline) textUnderline.style.background = state.textUnderline ? '#fb8c00' : '#37474f'; + } catch {} + app?.canvas?.setDirty(true, true); + }); + } + // Clamp initial position and anchor to button const clamp = (px, py) => { const nx = Math.max(0, Math.min(window.innerWidth - menu.offsetWidth, px)); @@ -1305,6 +4936,14 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { menu.style.top = cl.ny + 'px'; ui.contextMenu = menu; + // UX: when opening Options from the floating button, snap the button to the menu corner + // so it looks "attached" (left corner when menu is on the left, right corner when on the right). + try { + if (anchorEl && ui.floating && anchorEl === ui.floating) { + snapFloatingToggleToContextMenu(menu); + } + } catch {} + // Wire context controls const tgl = menu.querySelector('#ctx_toggle'); const clr = menu.querySelector('#ctx_clear'); @@ -1319,8 +4958,30 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const hdp = menu.querySelector('#ctx_hidpi'); const hid = menu.querySelector('#ctx_hidden'); const pen = menu.querySelector('#ctx_penonly'); + const pinm = menu.querySelector('#ctx_pinmode'); + const selovr = menu.querySelector('#ctx_sel_override'); ui.hiddenChk = hid; // Store reference for keyboard shortcut const swf = menu.querySelector('#ctx_savewf'); + const toolDrawBtn = menu.querySelector('#ctx_tool_draw'); + const toolSelectBtn = menu.querySelector('#ctx_tool_select'); + const toolTransformBtn = menu.querySelector('#ctx_tool_transform'); + const toolRotateBtn = menu.querySelector('#ctx_tool_rotate'); + const toolShotBtn = menu.querySelector('#ctx_tool_shot'); + const selMode = menu.querySelector('#ctx_select_mode'); + const tfMode = menu.querySelector('#ctx_transform_mode'); + const selClearBtn = menu.querySelector('#ctx_sel_clear'); + const selCopyBtn = menu.querySelector('#ctx_sel_copy'); + const selCutBtn = menu.querySelector('#ctx_sel_cut'); + const selPasteBtn = menu.querySelector('#ctx_sel_paste'); + const undoBtn = menu.querySelector('#ctx_undo'); + const redoBtn = menu.querySelector('#ctx_redo'); + const stColor = menu.querySelector('#ctx_sticker_color'); + const stPad = menu.querySelector('#ctx_sticker_pad'); + const stBorder = menu.querySelector('#ctx_sticker_border'); + const stShadow = menu.querySelector('#ctx_sticker_shadow'); + const stShadowStrength = menu.querySelector('#ctx_sticker_shadow_strength'); + const stClearBtn = menu.querySelector('#ctx_sticker_clear'); + const stPurgeBtn = menu.querySelector('#ctx_sticker_purge'); const exp = menu.querySelector('#ctx_export'); const imp = menu.querySelector('#ctx_import'); const cls = menu.querySelector('#ctx_close'); @@ -1330,6 +4991,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Update menu button color immediately tgl.style.background = state.enabled ? '#4CAF50' : '#f44336'; tgl.textContent = state.enabled ? 'ENABLED' : 'DISABLED'; + try { __iamccsFlashCornerBadge(tgl, 'done', state.enabled ? 'ON' : 'OFF', { ms: 800, bg: '#ff9800' }); } catch {} }); clr.addEventListener('click', () => { // Clear all non-locked layers @@ -1338,41 +5000,169 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { state.paths = []; for (const lyr of state.layers) for (const p of lyr.paths) state.paths.push(p); app?.canvas?.setDirty(true, true); - if (state.saveWithWorkflow) persistToGraphExtra(true); else removeFromGraphExtra(); + persistToGraphExtra(true); + if (!state.saveWithWorkflow) removeFromGraphExtra(); + try { __iamccsFlashCornerBadge(clr, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Annotations cleared (unlocked layers)', { kind: 'info', ms: 1200 }); } catch {} + }); + col.addEventListener('input', () => { + state.color = col.value; + schedulePaletteAutoHide(col); + try { saveStateToLayerStyle(); } catch {} + try { + const didText = syncTextLayerFromStateColor(); + if (!didText) persistToGraphExtra(); + } catch { try { persistToGraphExtra(); } catch {} } }); - col.addEventListener('input', () => { state.color = col.value; }); w.addEventListener('input', () => { state.width = parseInt(w.value, 10) || 3; if (state.eraser) state.widthErase = state.width; else state.widthDraw = state.width; wv.textContent = String(state.width); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); op.addEventListener('input', () => { const pct = parseInt(op.value,10)||100; state.opacity = Math.max(0.1, Math.min(1, pct/100)); if (state.eraser) state.opacityErase = state.opacity; else state.opacityDraw = state.opacity; ov.textContent = String(Math.round(state.opacity*100)); + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} }); ers.addEventListener('click', () => { setEraserMode(!state.eraser); // Update context button state immediately ers.textContent = state.eraser ? '🩹 Eraser' : '✏️ Draw'; ers.style.background = state.eraser ? '#c2185b' : '#795548'; + try { showToast(state.eraser ? 'Eraser enabled' : 'Eraser disabled', { kind: 'info', ms: 1100 }); } catch {} + try { __iamccsFlashCornerBadge(ers, 'done', state.eraser ? 'ER' : 'DR', { ms: 800, bg: '#ff9800' }); } catch {} + try { syncToolsUI(); } catch {} }); cst.addEventListener('change', () => { state.constantScreen = !!cst.checked; app?.canvas?.setDirty(true,true); }); - dsh.addEventListener('change', () => { state.dashed = !!dsh.checked; app?.canvas?.setDirty(true,true); }); + dsh.addEventListener('change', () => { + state.dashed = !!dsh.checked; + try { saveStateToLayerStyle(undefined, { persist: true }); } catch {} + app?.canvas?.setDirty(true,true); + }); hdp.addEventListener('change', () => { state.hiDPIx2 = !!hdp.checked; app?.canvas?.setDirty(true,true); }); - hid.addEventListener('change', () => { state.hidden = !!hid.checked; app?.canvas?.setDirty(true,true); }); - pen.addEventListener('change', () => { state.penOnly = !!pen.checked; }); - swf.addEventListener('change', () => { state.saveWithWorkflow = !!swf.checked; if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); }); - exp.addEventListener('click', () => exportAnnotations()); + hid.addEventListener('change', () => { state.hidden = !!hid.checked; try { showToast(state.hidden ? 'Hide notes enabled' : 'Hide notes disabled', { kind: 'info', ms: 1200 }); } catch {} try { syncFlagsUI(); } catch {} app?.canvas?.setDirty(true,true); }); + pen.addEventListener('change', () => { state.penOnly = !!pen.checked; try { showToast(state.penOnly ? 'Pen only enabled' : 'Pen only disabled', { kind: 'info', ms: 1200 }); } catch {} try { syncFlagsUI(); } catch {} }); + pinm?.addEventListener('change', () => { state.pinMode = !!pinm.checked; try { showToast(state.pinMode ? 'Pin mode enabled' : 'Pin mode disabled', { kind: 'info', ms: 1200 }); } catch {} try { syncFlagsUI(); } catch {} app?.canvas?.setDirty(true, true); }); + selovr?.addEventListener('change', () => { + state.selOverride = !!selovr.checked; + // If Annotate is ON, enabling SEL means the user expects to select, not draw. + if (state.enabled && state.selOverride) { + try { setTool('select'); } catch {} + } + if (!state.selOverride && !state.enabled) { + try { clearSelection(); } catch {} + } + try { showToast(state.selOverride ? 'Selection mode enabled' : 'Selection mode disabled', { kind: 'info', ms: 1200 }); } catch {} + try { syncFlagsUI(); } catch {} + app?.canvas?.setDirty(true, true); + }); + swf.addEventListener('change', () => { + state.saveWithWorkflow = !!swf.checked; + if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); + try { showToast(state.saveWithWorkflow ? 'Save into workflow enabled' : 'Save into workflow disabled', { kind: 'info', ms: 1200 }); } catch {} + }); + + const setToolAutoEnable = (t) => { + try { + if (!state.enabled) setEnabled(true); + } catch {} + setTool(t); + syncToolsUI(); + }; + + toolDrawBtn?.addEventListener('click', () => { setToolAutoEnable('draw'); try { __iamccsFlashCornerBadge(toolDrawBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Draw enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolSelectBtn?.addEventListener('click', () => { setToolAutoEnable('select'); try { __iamccsFlashCornerBadge(toolSelectBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Select enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolTransformBtn?.addEventListener('click', () => { setToolAutoEnable('transform'); try { __iamccsFlashCornerBadge(toolTransformBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Transform enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolRotateBtn?.addEventListener('click', () => { setToolAutoEnable('rotate'); try { __iamccsFlashCornerBadge(toolRotateBtn, 'done', 'ON', { ms: 700, bg: '#ff9800' }); } catch {} try { showToast('Rotate enabled', { kind: 'info', ms: 900 }); } catch {} }); + toolShotBtn?.addEventListener('click', () => { setToolAutoEnable('screenshot'); try { __iamccsFlashCornerBadge(toolShotBtn, 'done', 'ON', { ms: 750, bg: '#ff9800' }); } catch {} try { showToast('Screenshot tool enabled', { kind: 'info', ms: 1000 }); } catch {} }); + selMode?.addEventListener('change', () => { setSelectMode(selMode.value); try { showToast(`Selection shape: ${String(selMode.value || 'rect')}`, { kind: 'info', ms: 1000 }); } catch {} }); + tfMode?.addEventListener('change', () => { setTransformMode(tfMode.value); try { showToast(`Transform mode: ${String(tfMode.value || 'fixed')}`, { kind: 'info', ms: 1000 }); } catch {} }); + selClearBtn?.addEventListener('click', () => { clearSelection(); try { __iamccsFlashCornerBadge(selClearBtn, 'done', 'OK', { ms: 800, bg: '#ff9800' }); } catch {} try { showToast('Selection cleared', { kind: 'info', ms: 900 }); } catch {} }); + selCopyBtn?.addEventListener('click', () => { const ok = copySelectionToClipboard(); try { __iamccsFlashCornerBadge(selCopyBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Selection copied' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + selCutBtn?.addEventListener('click', () => { const ok = copySelectionToClipboard({ clearAfter: false }); if (ok) deleteSelection({ allowLocked: false }); try { __iamccsFlashCornerBadge(selCutBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Selection cut' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + selPasteBtn?.addEventListener('click', () => { const ok = pasteClipboardAt(state.lastPointerGraphPos); try { __iamccsFlashCornerBadge(selPasteBtn, 'done', ok ? 'OK' : 'NO', { ms: 900, bg: '#ff9800' }); } catch {} try { showToast(ok ? 'Pasted' : 'Clipboard empty', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} }); + + undoBtn?.addEventListener('click', () => { + const ok = doUndo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(undoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Undo' : 'Nothing to undo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + redoBtn?.addEventListener('click', () => { + const ok = doRedo(); + if (ok) persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(redoBtn, 'done', ok ? 'OK' : 'NO', { ms: 850, bg: '#ff9800' }); } catch {} + try { showToast(ok ? 'Redo' : 'Nothing to redo', { kind: ok ? 'info' : 'warn', ms: 900 }); } catch {} + }); + + stColor?.addEventListener('input', () => { + state.stickerFrameColor = stColor.value; + schedulePaletteAutoHide(stColor); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stPad?.addEventListener('input', () => { + state.stickerPaddingPx = parseInt(stPad.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stBorder?.addEventListener('input', () => { + state.stickerBorderWidthPx = parseInt(stBorder.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadow?.addEventListener('change', () => { + state.stickerShadow = !!stShadow.checked; + syncStickerUI(); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stShadowStrength?.addEventListener('input', () => { + state.stickerShadowStrength = parseInt(stShadowStrength.value, 10) || 0; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + }); + stClearBtn?.addEventListener('click', () => { + state.stickers = []; + clearSelection(); + app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); + try { __iamccsFlashCornerBadge(stClearBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Post-its cleared', { kind: 'info', ms: 1100 }); } catch {} + }); + + stPurgeBtn?.addEventListener('click', async () => { + await purgeOldStickerScreenshotCacheForCurrentWorkflow(); + try { __iamccsFlashCornerBadge(stPurgeBtn, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Old screenshot cache purged', { kind: 'info', ms: 1200 }); } catch {} + }); + + exp.addEventListener('click', () => { + exportAnnotations(); + try { __iamccsFlashCornerBadge(exp, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Export started', { kind: 'info', ms: 1100 }); } catch {} + }); imp.addEventListener('click', () => { + try { __iamccsFlashCornerBadge(imp, 'done', 'OK', { ms: 900, bg: '#ff9800' }); } catch {} + try { showToast('Choose JSON to import', { kind: 'info', ms: 1100 }); } catch {} const inp = document.createElement('input'); inp.type = 'file'; inp.accept = 'application/json'; inp.onchange = () => { if (inp.files && inp.files[0]) importAnnotations(inp.files[0]); }; inp.click(); }); - cls.addEventListener('click', () => { menu.remove(); }); + cls.addEventListener('click', () => { + try { __iamccsFlashCornerBadge(cls, 'done', 'OK', { ms: 700, bg: '#ff9800' }); } catch {} + try { showToast('Options closed', { kind: 'info', ms: 900 }); } catch {} + menu.remove(); + }); + + syncUI(); + syncToolsUI(); + syncStickerUI(); __ctxOffClick = (ev) => { if (!menu.contains(ev.target) && ev.target !== ui.floating) { closeContextMenu('off-click'); @@ -1396,8 +5186,9 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Fallback: compute from ds and bounding rect const rect = canvas.canvas.getBoundingClientRect(); const ds = canvas.ds || canvas.viewport || { scale: canvas.scale || 1, offset: canvas.offset || [0, 0] }; - const x = (e.clientX - rect.left) / (window.devicePixelRatio || 1); - const y = (e.clientY - rect.top) / (window.devicePixelRatio || 1); + const dpr = getCanvasEffectiveDpr(canvas); + const x = (e.clientX - rect.left) / dpr; + const y = (e.clientY - rect.top) / dpr; // In litegraph: screen = (graph + offset) * scale -> graph = (screen/scale) - offset return { x: x / ds.scale - ds.offset[0], y: y / ds.scale - ds.offset[1] }; } @@ -1416,21 +5207,71 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { canvas.onDrawForeground = function (ctx) { if (typeof prev === 'function') prev.call(this, ctx); // Written code by Carmine Cristallo Scalzi (IAMCCS) - AI for debugging - section: Foreground Rendering Pipeline - reason: explains offscreen buffer compositing to preserve correct eraser behavior and stroke quality - // Create floating button on first draw (proper timing) - if (!state.uiShown) { + // Create (or recreate) floating button on draw (proper timing) + if (!state.uiShown || !ui.floating || !document.body.contains(ui.floating)) { console.log('[IAMCCS] Creating floating button...'); ensureFloatingToggle(); state.uiShown = true; } - if (state.hidden) return; // do not render when hidden + // Subgraph separation: switch stored annotation set when active graph changes + try { + const keyNow = getActiveGraphKey(); + if (!state._switchingGraph && keyNow !== state._activeGraphKey) { + state._switchingGraph = true; + const prevKey = state._activeGraphKey || 'root'; + // Save current state into previous key before switching + if (state.saveWithWorkflow && app?.graph) { + try { writeAnnotationsToExtra(prevKey, buildAnnotationsPayload()); } catch {} + } + // Switch and load + state._activeGraphKey = keyNow; + state.hydrated = false; + const loaded = loadFromGraphExtra(); + if (!loaded) { + state.paths = []; + state.current = null; + state.stickers = []; + state.selection = null; + state.currentLayerIdx = 0; + state.layers = [{ + name: 'Layer 1', + visible: true, + locked: false, + paths: [], + style: { + color: state.color || '#ff4444', + dashed: !!state.dashed, + widthDraw: state.widthDraw || 7, + widthErase: state.widthErase || 48, + opacityDraw: (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0), + opacityErase: (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0), + } + }]; + state.hydrated = true; + } + try { syncUI(); } catch {} + try { syncBrushOpacityUI(); } catch {} + try { syncToolsUI(); } catch {} + try { syncStickerUI(); } catch {} + // macro removed + state._switchingGraph = false; + } + } catch { + state._switchingGraph = false; + } + const hideNonLocked = !!state.hidden; const cw = ctx.canvas.width | 0; const ch = ctx.canvas.height | 0; - const supersample = state.hiDPIx2 ? 2 : 1; + + // Supersampling factor for the offscreen stroke buffer (keep at 1 for stability) + const supersample = 1; + if (!__annoCanvas) { __annoCanvas = document.createElement('canvas'); __annoCtx = __annoCanvas.getContext('2d'); } + const targetW = Math.max(1, Math.floor(cw * supersample)); const targetH = Math.max(1, Math.floor(ch * supersample)); if (__annoW !== targetW || __annoH !== targetH) { @@ -1441,12 +5282,15 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } const ds = canvas.ds || canvas.viewport || { scale: canvas.scale || 1, offset: canvas.offset || [0, 0] }; - const dpr = (window.devicePixelRatio || 1) * supersample; + const dpr = getCanvasEffectiveDpr(canvas) * supersample; + const sc = (ds.scale || 1); + const off0 = (ds.offset?.[0] || 0); + const off1 = (ds.offset?.[1] || 0); const g2s = (pt) => ({ - x: ((pt.x + (ds.offset?.[0] || 0)) * (ds.scale || 1)) * dpr, - y: ((pt.y + (ds.offset?.[1] || 0)) * (ds.scale || 1)) * dpr, + x: ((pt.x + off0) * sc) * dpr, + y: ((pt.y + off1) * sc) * dpr, }); - const widthPx = (w) => state.constantScreen ? (w * dpr) : (w * (ds.scale || 1) * dpr); + const widthPx = (w) => state.constantScreen ? (w * dpr) : (w * sc * dpr); const dashForWidth = (wpx) => { // Dash pattern scales with stroke width so it remains visible at any size const a = Math.max(2, Math.min(200, Math.round(3 * wpx))); @@ -1462,6 +5306,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Draw stored strokes for (const layer of state.layers) { if (!layer.visible) continue; + if (hideNonLocked && !layer.locked) continue; for (const path of layer.paths) { if (!Array.isArray(path.points) || !path.points.length) continue; const wpx = widthPx(path.width); @@ -1535,7 +5380,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } // Draw current stroke const cur = state.current; - if (cur && Array.isArray(cur.points) && cur.points.length) { + if (!hideNonLocked && cur && Array.isArray(cur.points) && cur.points.length) { const wpx = widthPx(cur.width); __annoCtx.globalAlpha = typeof cur.opacity === 'number' ? cur.opacity : 1; if (cur.dashed) __annoCtx.setLineDash(dashForWidth(wpx)); else __annoCtx.setLineDash([]); @@ -1604,38 +5449,1752 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { } __annoCtx.restore(); - // Composite the offscreen buffer back to the main canvas - ctx.save(); - ctx.setTransform(1,0,0,1,0,0); - ctx.imageSmoothingEnabled = true; - ctx.globalCompositeOperation = 'source-over'; - // Composite with downscale if supersampled - ctx.drawImage(__annoCanvas, 0, 0, __annoW, __annoH, 0, 0, cw, ch); - ctx.restore(); + // Stickers + screenshot flash + // Order: image post-its -> strokes -> text boxes + try { + const dprMain = getCanvasEffectiveDpr(canvas); + const off0 = (ds.offset?.[0] || 0); + const off1 = (ds.offset?.[1] || 0); + const sc = (ds.scale || 1); + const g2c = (pt) => ({ + x: ((pt.x + off0) * sc) * dprMain, + y: ((pt.y + off1) * sc) * dprMain, + }); + + const pad = Math.max(0, state.stickerPaddingPx || 0) * dprMain; + const bw = Math.max(0, state.stickerBorderWidthPx || 0) * dprMain; + const radius = 10 * dprMain; + + // 1) IMAGE post-its (screenshots) + if (!hideNonLocked && Array.isArray(state.stickers) && state.stickers.length) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.imageSmoothingEnabled = true; + for (const st of state.stickers) { + if (!st || isTextSticker(st)) continue; + if (!st.dataUrl && st.dataKey) { + // Lazy-load screenshot from IndexedDB if available + requestHydrateStickerData(st.id, st.dataKey); + } + const rot = (typeof st.rot === 'number') ? st.rot : 0; + const pC = g2c({ x: (st.x + (st.w || 0) / 2), y: (st.y + (st.h || 0) / 2) }); + const p0 = g2c({ x: st.x, y: st.y }); + const p1 = g2c({ x: st.x + st.w, y: st.y + st.h }); + const w = Math.max(1, Math.abs(p1.x - p0.x)); + const h = Math.max(1, Math.abs(p1.y - p0.y)); + ctx.save(); + ctx.translate(pC.x, pC.y); + if (rot) ctx.rotate(rot); + const x = -w / 2; + const y = -h / 2; + + if (state.stickerShadow) { + ctx.shadowColor = 'rgba(0,0,0,0.35)'; + ctx.shadowBlur = Math.max(0, (state.stickerShadowStrength || 0) * dprMain); + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = Math.max(1, 3 * dprMain); + } else { + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + } + roundedRectPath(ctx, x, y, w, h, radius); + ctx.fillStyle = state.stickerFrameColor || '#ffffff'; + ctx.fill(); + + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + if (bw > 0.25) { + roundedRectPath(ctx, x, y, w, h, radius); + ctx.lineWidth = Math.max(1, bw); + ctx.strokeStyle = 'rgba(0,0,0,0.45)'; + ctx.stroke(); + } + + if (st.pinned) { + const pr = 7 * dprMain; + const px = x + pr + 6 * dprMain; + const py = y + pr + 6 * dprMain; + ctx.save(); + ctx.fillStyle = 'rgba(220,40,40,0.95)'; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.lineWidth = Math.max(1, 1.5 * dprMain); + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(px, py + pr * 0.2); + ctx.lineTo(px, py + pr * 1.65); + ctx.stroke(); + ctx.restore(); + } + + const ix = x + pad + bw; + const iy = y + pad + bw; + const iw = Math.max(1, w - 2 * (pad + bw)); + const ih = Math.max(1, h - 2 * (pad + bw)); + if (st.dataUrl) { + let img = __stickerImageCache.get(st.dataUrl); + if (!img) { + img = new Image(); + img.onload = () => app?.canvas?.setDirty(true, true); + img.src = st.dataUrl; + __stickerImageCache.set(st.dataUrl, img); + } + if (img && img.complete && img.naturalWidth > 0) { + ctx.save(); + roundedRectPath(ctx, ix, iy, iw, ih, Math.max(0, radius - (pad + bw))); + ctx.clip(); + ctx.drawImage(img, ix, iy, iw, ih); + ctx.restore(); + } + } else { + // Placeholder while screenshot loads + ctx.save(); + roundedRectPath(ctx, ix, iy, iw, ih, Math.max(0, radius - (pad + bw))); + ctx.clip(); + ctx.fillStyle = 'rgba(0,0,0,0.10)'; + ctx.fillRect(ix, iy, iw, ih); + ctx.strokeStyle = 'rgba(0,0,0,0.18)'; + ctx.lineWidth = Math.max(1, 1 * dprMain); + ctx.strokeRect(ix, iy, iw, ih); + ctx.restore(); + } + ctx.restore(); + } + ctx.restore(); + } + + // 2) Strokes (annotations) above screenshots + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.imageSmoothingEnabled = true; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(__annoCanvas, 0, 0, __annoW, __annoH, 0, 0, cw, ch); + ctx.restore(); + + // 3) TEXT boxes above strokes + if (Array.isArray(state.stickers) && state.stickers.length) { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.imageSmoothingEnabled = true; + for (const st of state.stickers) { + if (!st || !isTextSticker(st)) continue; + if (!isTextStickerVisible(st)) continue; + if (hideNonLocked) { + let keep = false; + try { + const li = findTextLayerIndexByStickerId(st.id); + const lyr = li >= 0 ? state.layers?.[li] : null; + keep = !!(lyr && lyr.locked); + } catch {} + if (!keep) continue; + } + const rot = (typeof st.rot === 'number') ? st.rot : 0; + const pC = g2c({ x: (st.x + (st.w || 0) / 2), y: (st.y + (st.h || 0) / 2) }); + const p0 = g2c({ x: st.x, y: st.y }); + const p1 = g2c({ x: st.x + st.w, y: st.y + st.h }); + const w = Math.max(1, Math.abs(p1.x - p0.x)); + const h = Math.max(1, Math.abs(p1.y - p0.y)); + ctx.save(); + ctx.translate(pC.x, pC.y); + if (rot) ctx.rotate(rot); + const x = -w / 2; + const y = -h / 2; + + // No frame/background for text boxes (text only) + ctx.shadowColor = 'transparent'; + ctx.shadowBlur = 0; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 0; + + if (st.pinned) { + const pr = 7 * dprMain; + const px = x + pr + 6 * dprMain; + const py = y + pr + 6 * dprMain; + ctx.save(); + ctx.fillStyle = 'rgba(220,40,40,0.95)'; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.lineWidth = Math.max(1, 1.5 * dprMain); + ctx.beginPath(); + ctx.arc(px, py, pr, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(px, py + pr * 0.2); + ctx.lineTo(px, py + pr * 1.65); + ctx.stroke(); + ctx.restore(); + } + + // Text boxes are text-only (no frame/background). Do NOT reuse screenshot padding/border, + // otherwise increasing Post-it padding/border can clip text and make it appear to “disappear”. + const textInset = 6 * dprMain; + const ix = x + textInset; + const iy = y + textInset; + const iw = Math.max(1, w - 2 * textInset); + const ih = Math.max(1, h - 2 * textInset); + ctx.save(); + roundedRectPath(ctx, ix, iy, iw, ih, Math.max(0, radius - textInset)); + ctx.clip(); + // Scale font with zoom so it behaves like strokes/screenshots + const fsBase = (Number(st.fontSize) || Number(state.textFontSize) || 28); + const fs = Math.max(8, fsBase * (ds.scale || 1) * dprMain); + const fam = String(st.fontFamily || state.textFontFamily || 'Arial'); + const fw = String(st.fontWeight || state.textFontWeight || 'normal'); + const fst = String(st.fontStyle || state.textFontStyle || 'normal'); + ctx.font = `${fst} ${fw} ${fs}px ${fam}`; + let textFill = st.textColor || state.color || state.textColor || '#111111'; + try { + const li = findTextLayerIndexByStickerId(st.id); + const lyr = li >= 0 ? state.layers[li] : null; + if (lyr?.style?.color) textFill = lyr.style.color; + } catch {} + ctx.fillStyle = String(textFill); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + const underline = (st.underline ?? state.textUnderline) ? true : false; + const lh = fs * 1.22; + const lines = wrapTextLines(ctx, st.text || '', Math.max(1, iw)); + let yy = iy; + for (const line of lines) { + if (yy + lh > iy + ih + 0.5) break; + ctx.fillText(line, ix, yy); + if (underline && line) { + const mw = ctx.measureText(line).width; + const uy = yy + fs * 1.05; + ctx.save(); + ctx.strokeStyle = String(textFill); + ctx.lineWidth = Math.max(1, fs / 14); + ctx.beginPath(); + ctx.moveTo(ix, uy); + ctx.lineTo(ix + mw, uy); + ctx.stroke(); + ctx.restore(); + } + yy += lh; + } + ctx.restore(); + ctx.restore(); + } + ctx.restore(); + } + // Flash feedback for screenshot capture (after capture) + if (state.shotFlash && state.shotFlash.t0) { + const dt = performance.now() - state.shotFlash.t0; + const dur = 180; + if (dt < dur) { + const a = Math.max(0, 1 - dt / dur); + const r = normalizeRect(state.shotFlash.rect); + if (r) { + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + 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)); + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = `rgba(255,255,255,${0.55 * a})`; + ctx.fillRect(x, y, w, h); + ctx.restore(); + } + } else { + state.shotFlash = null; + } + } + } catch { + // Fallback: ensure strokes are visible even if sticker rendering failed + try { + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.imageSmoothingEnabled = true; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(__annoCanvas, 0, 0, __annoW, __annoH, 0, 0, cw, ch); + ctx.restore(); + } catch {} + } + + // Keep text editor (if any) aligned with the graph + try { if (state.textEditor) syncTextEditorPosition(canvas); } catch {} + + // When hidden, preserve legacy behavior: no selection/creation overlays + if (hideNonLocked) return; + + // Overlays (selection/screenshot rectangle) + if (state.selection) { + try { + const sel = state.selection; + const dprMain = getCanvasEffectiveDpr(canvas); + const off0 = (ds.offset?.[0] || 0); + const off1 = (ds.offset?.[1] || 0); + const sc = (ds.scale || 1); + const g2c = (pt) => ({ + x: ((pt.x + off0) * sc) * dprMain, + y: ((pt.y + off1) * sc) * dprMain, + }); + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.lineJoin = 'round'; + ctx.lineCap = 'round'; + ctx.lineWidth = Math.max(1, 2 * dprMain); + ctx.setLineDash([6 * dprMain, 4 * dprMain]); + + // Screenshot tool: always show live selection rectangle + if (sel.kind === 'screenshot') { + ctx.strokeStyle = 'rgba(255,255,255,0.95)'; + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + if (sel.mode === 'rect' && sel.rect) { + const r = normalizeRect(sel.rect); + if (r && r.w > 0.0001 && r.h > 0.0001) { + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x = Math.min(p0.x, p1.x); + const y = Math.min(p0.y, p1.y); + const w = Math.abs(p1.x - p0.x); + const h = Math.abs(p1.y - p0.y); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); + return; + } + + // Text tool: show live sizing rectangle while dragging + if (sel.kind === 'text') { + ctx.strokeStyle = 'rgba(3,155,229,0.95)'; + ctx.fillStyle = 'rgba(3,155,229,0.10)'; + if (sel.mode === 'rect' && sel.rect) { + const r = normalizeRect(sel.rect); + if (r && r.w > 0.0001 && r.h > 0.0001) { + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x = Math.min(p0.x, p1.x); + const y = Math.min(p0.y, p1.y); + const w = Math.abs(p1.x - p0.x); + const h = Math.abs(p1.y - p0.y); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + } + } + ctx.restore(); + return; + } + + // Select/Transform/Rotate: show a selection rectangle ONLY while the user is dragging to create it. + if (sel.kind === 'select' && sel.creating) { + ctx.strokeStyle = 'rgba(0,200,255,0.95)'; + ctx.fillStyle = 'rgba(0,200,255,0.08)'; + if (sel.mode === 'rect' && sel.rect) { + const r = normalizeRect(sel.rect); + if (r && r.w > 0.0001 && r.h > 0.0001) { + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x = Math.min(p0.x, p1.x); + const y = Math.min(p0.y, p1.y); + const w = Math.abs(p1.x - p0.x); + const h = Math.abs(p1.y - p0.y); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.fill(); + ctx.stroke(); + } + } else if (Array.isArray(sel.points) && sel.points.length > 1) { + const pts = sel.points.map(g2c); + ctx.beginPath(); + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y); + ctx.stroke(); + } + ctx.restore(); + return; + } + + // Once selection exists, highlight selected items (no large group frame) + if (sel.kind === 'select' && ((sel.selectedPaths?.length || 0) > 0 || (sel.selectedStickers?.length || 0) > 0)) { + ctx.strokeStyle = 'rgba(255,180,0,0.95)'; + ctx.fillStyle = 'rgba(255,180,0,0.00)'; + + // Highlight selected paths by their bboxes + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + // Always compute live bbox so the frame follows while items move + const bb = pathBBox(path); + if (!bb) continue; + const r = normalizeRect(bb); + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x = Math.min(p0.x, p1.x); + const y = Math.min(p0.y, p1.y); + const w = Math.abs(p1.x - p0.x); + const h = Math.abs(p1.y - p0.y); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.stroke(); + } + + // Highlight selected stickers by their (axis-aligned) bbox + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + // Always compute live bbox so the frame follows while items move + const bb = stickerBBox(st); + if (!bb) continue; + const r = normalizeRect(bb); + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x = Math.min(p0.x, p1.x); + const y = Math.min(p0.y, p1.y); + const w = Math.abs(p1.x - p0.x); + const h = Math.abs(p1.y - p0.y); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.stroke(); + } + + // Transform/Rotate: draw only the 4 corner handles of the overall bbox + if (state.tool === 'transform' || state.tool === 'rotate') { + // Derive bbox from current selection (avoids stale sel.bbox causing slight offsets) + let bbAll = null; + try { + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const bb = pathBBox(path); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const bb = stickerBBox(st); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + } catch {} + + const bbUse = bbAll || sel.bbox; + if (!bbUse) { + // nothing + } else { + const r = normalizeRect(bbUse); + if (r && isFinite(r.x0) && isFinite(r.y0) && isFinite(r.x1) && isFinite(r.y1)) { + const p0 = g2c({ x: r.x0, y: r.y0 }); + const p1 = g2c({ x: r.x1, y: r.y1 }); + const x0 = Math.min(p0.x, p1.x); + const y0 = Math.min(p0.y, p1.y); + const x1 = Math.max(p0.x, p1.x); + const y1 = Math.max(p0.y, p1.y); + const hs = 7 * dprMain; // half-size of handle + ctx.save(); + ctx.setLineDash([]); + ctx.lineWidth = Math.max(1, 2 * dprMain); + ctx.fillStyle = 'rgba(0,200,255,0.95)'; + ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + const corners = [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + ]; + for (const c of corners) { + ctx.beginPath(); + ctx.rect(c.x - hs, c.y - hs, hs * 2, hs * 2); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + } + } + } + } + + ctx.restore(); + } catch {} + } }; // Pointer events on the graph canvas only const el = canvas.canvas; const onDown = (e) => { - if (!state.enabled) return; if (e.target && e.target.closest('#iamccs-sidebar')) return; if (e.button !== 0) return; // only left button draws; allow middle-button pan - if (state.penOnly && e.pointerType && e.pointerType !== 'pen') return; const p = toGraphPos(e, canvas); if (!p) return; + state.lastPointerGraphPos = p; + + // Pin/unpin mode: click a sticker to toggle pinned state (prevents moving/resizing/rotating) + if (state.enabled && state.pinMode) { + const hit = hitTestStickerInside(p, canvas); + if (hit >= 0) { + const st = state.stickers?.[hit]; + if (st) { + pushHistorySnapshot(); + st.pinned = !st.pinned; + // If we pinned it while dragging was in progress, clear drags + state.stickerDrag = null; + state.stickerResize = null; + state.transformDrag = null; + state.rotateDrag = null; + state.activePointerId = null; + persistToGraphExtra(true); + app?.canvas?.setDirty(true, true); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + } + + // When sel override is enabled while Annotate is OFF, route sticker clicks through the selection pipeline + // so the selection rectangle is visible. + const allowDirectStickerDrag = state.enabled || !state.selOverride; + if (allowDirectStickerDrag) { + // Sticker border drag should work regardless of tool (unless the tool needs the corner for resize/rotate) + const cornerHitForTool = (state.enabled && (state.tool === 'transform' || state.tool === 'rotate')) ? hitTestStickerCorner(p, canvas, 14) : null; + const stBorderIdx = hitTestStickerBorder(p, canvas, 20); + if (stBorderIdx >= 0 && !(cornerHitForTool && cornerHitForTool.stickerIdx === stBorderIdx)) { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + const st = state.stickers?.[stBorderIdx]; + if (st) { + // If pinned, don't drag the sticker; allow the current tool (e.g. Draw) to handle the gesture. + if (st.pinned) { + try { if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(e.pointerId); } catch {} + state.activePointerId = null; + } else { + const anchored0 = (isImageSticker(st) && st.id) ? collectAnchoredChildrenSnapshot(st.id) : null; + state.stickerDrag = { stickerIdx: stBorderIdx, start: { x: p.x, y: p.y }, x0: st.x, y0: st.y, anchored0 }; + el.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + } + + // Allow moving screenshot post-its (image stickers) by dragging anywhere inside (not only borders). + // Keep corner interactions for Transform/Rotate tools. + const stInsideImgIdx = hitTestImageStickerInside(p, canvas); + if (stInsideImgIdx >= 0 && !(cornerHitForTool && cornerHitForTool.stickerIdx === stInsideImgIdx)) { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + const st = state.stickers?.[stInsideImgIdx]; + if (st) { + // If pinned, don't drag the sticker; allow the current tool (e.g. Draw) to handle the gesture. + if (st.pinned) { + try { if (typeof el.releasePointerCapture === 'function') el.releasePointerCapture(e.pointerId); } catch {} + state.activePointerId = null; + } else { + const anchored0 = (isImageSticker(st) && st.id) ? collectAnchoredChildrenSnapshot(st.id) : null; + state.stickerDrag = { stickerIdx: stInsideImgIdx, start: { x: p.x, y: p.y }, x0: st.x, y0: st.y, anchored0 }; + el.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + } + } + + // When Annotate is OFF, allow only selection/move (sel override) or sticker dragging; otherwise let everything pass through. + if (!state.enabled) { + if (!state.selOverride) return; + + // Click-to-select a single sticker/path and drag-move it (like the Selection tool) + const hitSel = __iamccs_selectSingleAtPoint(p, canvas); + if (!hitSel) { + // Don't consume the gesture so ComfyUI can still pan/drag nodes + state.selection = null; + app?.canvas?.setDirty(true, true); + return; + } + + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + state.selection = hitSel; + + const sel = state.selection; + if (sel?.bbox) { + sel.dragging = false; + sel.pendingDrag = true; + sel.dragStart = { x: p.x, y: p.y }; + sel.dragData = { bbox0: sel.bbox ? { ...sel.bbox } : null, paths: [], stickers: [] }; + + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path) continue; + if (layer?.locked) continue; + sel.dragData.paths.push({ + layerIdx: it.layerIdx, + pathIdx: it.pathIdx, + points0: Array.isArray(path.points) ? path.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + if (st.pinned) continue; + sel.dragData.stickers.push({ stickerIdx: it.stickerIdx, x0: st.x, y0: st.y }); + } + + // Move anchored children with an image sticker even if not explicitly selected + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const agg = { paths: [], stickers: [] }; + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + if (s0?.paths?.length) agg.paths.push(...s0.paths); + if (s0?.stickers?.length) agg.stickers.push(...s0.stickers); + } + sel.dragData.anchored0 = agg; + } catch {} + } + + el.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + app?.canvas?.setDirty(true, true); + return; + } + + // Double-click a text sticker to edit its contents + try { + if (e.detail === 2) { + const hitIdx = hitTestStickerInside(p, canvas); + const st = (hitIdx >= 0) ? state.stickers?.[hitIdx] : null; + if (st && isTextSticker(st) && !st.pinned) { + openTextEditorForStickerId(st.id, canvas); + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + } catch {} + + // Tool routing: keep draw pipeline intact, add non-draw tools around it. + if (state.tool === 'select') { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + const sel = state.selection; + if (sel?.bbox && pointInRect(p, sel.bbox)) { + // Start dragging selection + sel.dragging = false; + sel.pendingDrag = true; + sel.dragStart = { x: p.x, y: p.y }; + sel.dragData = { + bbox0: sel.bbox ? { ...sel.bbox } : null, + paths: [], + stickers: [], + }; + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path) continue; + if (layer.locked) continue; + sel.dragData.paths.push({ + layerIdx: it.layerIdx, + pathIdx: it.pathIdx, + points0: Array.isArray(path.points) ? path.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + if (st.pinned) continue; + sel.dragData.stickers.push({ + stickerIdx: it.stickerIdx, + x0: st.x, + y0: st.y, + }); + } + + // If an image sticker (screenshot) is moved without selecting its children, + // still move anchored children with it. + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const agg = { paths: [], stickers: [] }; + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + if (s0?.paths?.length) agg.paths.push(...s0.paths); + if (s0?.stickers?.length) agg.stickers.push(...s0.stickers); + } + sel.dragData.anchored0 = agg; + } catch {} + } else { + // Click-to-select (single item) when Annotate is enabled. + // This matches the behavior of selOverride selection when Annotate is disabled. + const hitSel = __iamccs_selectSingleAtPoint(p, canvas); + if (hitSel) { + state.selection = hitSel; + const s2 = state.selection; + if (s2?.bbox) { + s2.dragging = false; + s2.pendingDrag = true; + s2.dragStart = { x: p.x, y: p.y }; + s2.dragData = { bbox0: s2.bbox ? { ...s2.bbox } : null, paths: [], stickers: [] }; + + for (const it of s2.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path) continue; + if (layer?.locked) continue; + s2.dragData.paths.push({ + layerIdx: it.layerIdx, + pathIdx: it.pathIdx, + points0: Array.isArray(path.points) ? path.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + }); + } + for (const it of s2.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + if (st.pinned) continue; + s2.dragData.stickers.push({ stickerIdx: it.stickerIdx, x0: st.x, y0: st.y }); + } + + // Move anchored children with selected image stickers even if not explicitly selected + try { + const skipStickerIds = new Set(); + for (const it of (s2.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (s2.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const agg = { paths: [], stickers: [] }; + for (const it of (s2.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + if (s0?.paths?.length) agg.paths.push(...s0.paths); + if (s0?.stickers?.length) agg.stickers.push(...s0.stickers); + } + s2.dragData.anchored0 = agg; + } catch {} + } + el.style.cursor = 'move'; + } else { + // Start a pending selection: we will create the rectangle/lasso only after a real drag. + state.pendingSelection = { tool: state.tool, kind: 'select', mode: state.selectMode, start: { x: p.x, y: p.y } }; + } + } + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'screenshot') { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + // Start a screenshot rectangle (capture happens on pointerup) + state.selection = { + kind: 'screenshot', + mode: 'rect', + start: { x: p.x, y: p.y }, + points: [{ x: p.x, y: p.y }], + rect: { x0: p.x, y0: p.y, x1: p.x, y1: p.y }, + bbox: null, + dragging: false, + }; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'text') { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + // Start sizing rectangle (text insert happens on pointerup) + state.selection = { + kind: 'text', + mode: 'rect', + start: { x: p.x, y: p.y }, + points: [{ x: p.x, y: p.y }], + rect: { x0: p.x, y0: p.y, x1: p.x, y1: p.y }, + bbox: null, + dragging: false, + }; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'transform') { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + + // 1) Stickers: resize by corners without selection + const hitC = hitTestStickerCorner(p, canvas, 12); + if (hitC && hitC.stickerIdx >= 0) { + const st = state.stickers?.[hitC.stickerIdx]; + if (st) { + if (st.pinned) { + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + const anchored0 = (isImageSticker(st) && st.id) ? collectAnchoredChildrenSnapshot(st.id) : null; + state.stickerResize = { + stickerIdx: hitC.stickerIdx, + corner: hitC.corner, + start: { x: p.x, y: p.y }, + x0: st.x, + y0: st.y, + w0: st.w, + h0: st.h, + aspect: (st.h ? (st.w / st.h) : 1), + rot0: (typeof st.rot === 'number') ? st.rot : 0, + anchored0, + }; + el.style.cursor = 'nwse-resize'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + + // 2) Stickers: move by border (optional) while in transform + const hitB = hitTestStickerBorder(p, canvas, 20); + if (hitB >= 0) { + const st = state.stickers?.[hitB]; + if (st) { + if (st.pinned) { + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + const anchored0 = (isImageSticker(st) && st.id) ? collectAnchoredChildrenSnapshot(st.id) : null; + state.stickerDrag = { stickerIdx: hitB, start: { x: p.x, y: p.y }, x0: st.x, y0: st.y, anchored0 }; + el.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + + // 3) Selection bbox: resize selected items (requires a selection already) + const sel = state.selection; + if (sel?.bbox && (sel.selectedPaths?.length || sel.selectedStickers?.length)) { + // Use a live bbox (union of selected items) to avoid stale sel.bbox offset + let bbAll = null; + try { + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const bb = pathBBox(path); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const bb = stickerBBox(st); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + } catch {} + const bboxUse = bbAll || sel.bbox; + const corner = bboxUse ? hitTestBBoxCorner(p, bboxUse, canvas, 20) : null; + if (corner) { + const bbox0 = { ...bboxUse }; + const items = { paths: [], stickers: [] }; + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer?.locked) continue; + items.paths.push({ layerIdx: it.layerIdx, pathIdx: it.pathIdx, points0: path.points.map(pt => ({ x: pt.x, y: pt.y })) }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || st.pinned) continue; + items.stickers.push({ stickerIdx: it.stickerIdx, x0: st.x, y0: st.y, w0: st.w, h0: st.h }); + } + + // Also scale anchored children of selected image stickers (screenshots), if not already selected + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const addPathKeys = new Set(); + for (const it of (items.paths || [])) addPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + const addStickerIdx = new Set(); + for (const it of (items.stickers || [])) addStickerIdx.add(it.stickerIdx); + + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + for (const pp of (s0.paths || [])) { + const k = `${pp.layerIdx}:${pp.pathIdx}`; + if (addPathKeys.has(k)) continue; + addPathKeys.add(k); + items.paths.push(pp); + } + for (const ss of (s0.stickers || [])) { + if (addStickerIdx.has(ss.stickerIdx)) continue; + addStickerIdx.add(ss.stickerIdx); + items.stickers.push({ stickerIdx: ss.stickerIdx, x0: ss.x0, y0: ss.y0, w0: ss.w0, h0: ss.h0 }); + } + } + } catch {} + const bw = Math.max(1e-6, (bbox0.x1 - bbox0.x0)); + const bh = Math.max(1e-6, (bbox0.y1 - bbox0.y0)); + state.transformDrag = { corner, start: { x: p.x, y: p.y }, bbox0, items, aspect: bw / bh }; + el.style.cursor = 'nwse-resize'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + + // Otherwise: behave like Select so user can create/select a group, then resize via corners + let bboxForMove = sel?.bbox; + try { + if (sel?.bbox && (sel.selectedPaths?.length || sel.selectedStickers?.length)) { + let bbAll2 = null; + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const bb = pathBBox(path); + if (bb) bbAll2 = bbAll2 ? bboxUnion(bbAll2, bb) : bb; + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const bb = stickerBBox(st); + if (bb) bbAll2 = bbAll2 ? bboxUnion(bbAll2, bb) : bb; + } + bboxForMove = bbAll2 || bboxForMove; + } + } catch {} + if (bboxForMove && pointInRect(p, bboxForMove)) { + sel.dragging = false; + sel.pendingDrag = true; + sel.dragStart = { x: p.x, y: p.y }; + sel.dragData = { + bbox0: bboxForMove ? { ...bboxForMove } : null, + paths: [], + stickers: [], + }; + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path) continue; + if (layer.locked) continue; + sel.dragData.paths.push({ + layerIdx: it.layerIdx, + pathIdx: it.pathIdx, + points0: Array.isArray(path.points) ? path.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + if (st.pinned) continue; + sel.dragData.stickers.push({ stickerIdx: it.stickerIdx, x0: st.x, y0: st.y }); + } + + // Also move anchored children of any selected image sticker (screenshot) + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const agg = { paths: [], stickers: [] }; + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + if (s0?.paths?.length) agg.paths.push(...s0.paths); + if (s0?.stickers?.length) agg.stickers.push(...s0.stickers); + } + sel.dragData.anchored0 = agg; + } catch {} + } else { + state.pendingSelection = { tool: state.tool, kind: 'select', mode: state.selectMode, start: { x: p.x, y: p.y } }; + } + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'rotate') { + try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} + state.activePointerId = e.pointerId; + + // 1) Stickers: rotate by dragging corners without needing selection + const hitC = hitTestStickerCorner(p, canvas, 12); + if (hitC && hitC.stickerIdx >= 0) { + const st = state.stickers?.[hitC.stickerIdx]; + if (st) { + if (st.pinned) { + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + const w = (typeof st.w === 'number') ? st.w : 0; + const h = (typeof st.h === 'number') ? st.h : 0; + const pivot = { x: (st.x + w / 2), y: (st.y + h / 2) }; + const items = { + paths: [], + stickers: [{ + stickerIdx: hitC.stickerIdx, + x0: st.x, + y0: st.y, + w0: st.w, + h0: st.h, + rot0: (typeof st.rot === 'number') ? st.rot : 0, + }], + }; + + // If rotating an image sticker (screenshot), rotate anchored children together + try { + if (isImageSticker(st) && st.id) { + const s0 = collectAnchoredChildrenSnapshot(st.id); + if (s0?.paths?.length) items.paths.push(...s0.paths); + if (s0?.stickers?.length) items.stickers.push(...s0.stickers); + } + } catch {} + state.rotateDrag = { + pivot, + startAngle: Math.atan2(p.y - pivot.y, p.x - pivot.x), + items, + }; + el.style.cursor = 'grabbing'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + + // 2) Selection: rotate by dragging bbox corners + const sel = state.selection; + if (sel?.bbox && (sel.selectedPaths?.length || sel.selectedStickers?.length)) { + // Use a live bbox (union of selected items) to avoid stale sel.bbox offset + let bbAll = null; + try { + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const bb = pathBBox(path); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const bb = stickerBBox(st); + if (bb) bbAll = bbAll ? bboxUnion(bbAll, bb) : bb; + } + } catch {} + const bboxUse = bbAll || sel.bbox; + const corner = bboxUse ? hitTestBBoxCorner(p, bboxUse, canvas, 20) : null; + if (corner) { + const pivot = rectCenter(bboxUse) || { x: (bboxUse.x0 + bboxUse.x1) / 2, y: (bboxUse.y0 + bboxUse.y1) / 2 }; + const items = { paths: [], stickers: [] }; + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer?.locked) continue; + items.paths.push({ layerIdx: it.layerIdx, pathIdx: it.pathIdx, points0: path.points.map(pt => ({ x: pt.x, y: pt.y })) }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || st.pinned) continue; + items.stickers.push({ + stickerIdx: it.stickerIdx, + x0: st.x, + y0: st.y, + w0: st.w, + h0: st.h, + rot0: (typeof st.rot === 'number') ? st.rot : 0, + }); + } + + // Also rotate anchored children of selected image stickers (screenshots), if not already selected + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const addPathKeys = new Set(); + for (const it of (items.paths || [])) addPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + const addStickerIdx = new Set(); + for (const it of (items.stickers || [])) addStickerIdx.add(it.stickerIdx); + + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + for (const pp of (s0.paths || [])) { + const k = `${pp.layerIdx}:${pp.pathIdx}`; + if (addPathKeys.has(k)) continue; + addPathKeys.add(k); + items.paths.push(pp); + } + for (const ss of (s0.stickers || [])) { + if (addStickerIdx.has(ss.stickerIdx)) continue; + addStickerIdx.add(ss.stickerIdx); + items.stickers.push(ss); + } + } + } catch {} + state.rotateDrag = { + pivot, + startAngle: Math.atan2(p.y - pivot.y, p.x - pivot.x), + items, + }; + el.style.cursor = 'grabbing'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + } + + // Otherwise: behave like Select so user can build a selection to rotate + let bboxForMove = sel?.bbox; + try { + if (sel?.bbox && (sel.selectedPaths?.length || sel.selectedStickers?.length)) { + let bbAll2 = null; + for (const it of sel.selectedPaths || []) { + const layer = state.layers?.[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const bb = pathBBox(path); + if (bb) bbAll2 = bbAll2 ? bboxUnion(bbAll2, bb) : bb; + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const bb = stickerBBox(st); + if (bb) bbAll2 = bbAll2 ? bboxUnion(bbAll2, bb) : bb; + } + bboxForMove = bbAll2 || bboxForMove; + } + } catch {} + if (bboxForMove && pointInRect(p, bboxForMove)) { + sel.dragging = false; + sel.pendingDrag = true; + sel.dragStart = { x: p.x, y: p.y }; + sel.dragData = { + bbox0: bboxForMove ? { ...bboxForMove } : null, + paths: [], + stickers: [], + }; + for (const it of sel.selectedPaths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path) continue; + if (layer.locked) continue; + sel.dragData.paths.push({ + layerIdx: it.layerIdx, + pathIdx: it.pathIdx, + points0: Array.isArray(path.points) ? path.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + }); + } + for (const it of sel.selectedStickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + if (st.pinned) continue; + sel.dragData.stickers.push({ stickerIdx: it.stickerIdx, x0: st.x, y0: st.y }); + } + + // Also move anchored children of any selected image sticker (screenshot) + try { + const skipStickerIds = new Set(); + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (st?.id) skipStickerIds.add(st.id); + } + const skipPathKeys = new Set(); + for (const it of (sel.selectedPaths || [])) { + if (typeof it?.layerIdx === 'number' && typeof it?.pathIdx === 'number') skipPathKeys.add(`${it.layerIdx}:${it.pathIdx}`); + } + const agg = { paths: [], stickers: [] }; + for (const it of (sel.selectedStickers || [])) { + const st = state.stickers?.[it.stickerIdx]; + if (!st || !isImageSticker(st) || !st.id) continue; + const s0 = collectAnchoredChildrenSnapshot(st.id, { skipStickerIds, skipPathKeys }); + if (s0?.paths?.length) agg.paths.push(...s0.paths); + if (s0?.stickers?.length) agg.stickers.push(...s0.stickers); + } + sel.dragData.anchored0 = agg; + } catch {} + } else { + state.pendingSelection = { tool: state.tool, kind: 'select', mode: state.selectMode, start: { x: p.x, y: p.y } }; + } + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + return; + } + + // Draw tool + try { + const curLayer = getCurrentLayer(); + if (curLayer?.kind === 'text') { + console.log('[IAMCCS] Cannot draw on a Text layer. Open a draw layer to draw.'); + try { + const now = Date.now(); + if (!state._lastTextLayerDrawWarnAt || (now - state._lastTextLayerDrawWarnAt) > 900) { + state._lastTextLayerDrawWarnAt = now; + showToast('Cannot draw on a Text layer. Open a draw layer to draw.', { kind: 'warn', ms: 1800 }); + } + } catch {} + e.preventDefault(); + e.stopPropagation(); + app?.canvas?.setDirty(true, false); + return; + } + } catch {} + if (state.penOnly && e.pointerType && e.pointerType !== 'pen') { + try { + const now = Date.now(); + if (!state._lastPenOnlyWarnAt || (now - state._lastPenOnlyWarnAt) > 900) { + state._lastPenOnlyWarnAt = now; + showToast('Pen-only mode is enabled. Use a pen or disable Pen-only.', { kind: 'warn', ms: 1800 }); + } + } catch {} + e.preventDefault(); + e.stopPropagation(); + app?.canvas?.setDirty(true, false); + return; + } // Capture pointer so we always receive pointerup even if leaving the canvas try { if (typeof el.setPointerCapture === 'function') el.setPointerCapture(e.pointerId); } catch {} state.activePointerId = e.pointerId; - state.current = { color: state.color, width: state.width, opacity: state.opacity, dashed: state.dashed, mode: state.eraser ? 'erase' : 'draw', points: [p] }; + + // Undo snapshot: one per stroke (draw/erase) + try { + const lyr = getCurrentLayer(); + if (lyr && !lyr.locked) pushHistorySnapshot(); + } catch {} + + let parentStickerId = null; + try { + const hitImg = hitTestImageStickerInside(p, canvas); + parentStickerId = (hitImg >= 0) ? (state.stickers?.[hitImg]?.id || null) : null; + } catch {} + state.current = { color: state.color, width: state.width, opacity: state.opacity, dashed: state.dashed, mode: state.eraser ? 'erase' : 'draw', parentStickerId: parentStickerId || undefined, points: [p] }; e.preventDefault(); e.stopPropagation(); app.canvas.setDirty(true, true); }; const onMove = (e) => { - if (!state.current) return; - if (state.penOnly && state.activePointerId != null && e.pointerId !== state.activePointerId) return; const p = toGraphPos(e, canvas); if (!p) return; + state.lastPointerGraphPos = p; + + // Sticker drag (draw/transform tools) + if (state.stickerDrag && state.activePointerId != null && e.pointerId === state.activePointerId) { + const d = state.stickerDrag; + const st = state.stickers?.[d.stickerIdx]; + if (!st) return; + if (st.pinned) { + state.stickerDrag = null; + } else { + if (!d._historyPushed) { + d._historyPushed = true; + pushHistorySnapshot(); + } + const dx = p.x - d.start.x; + const dy = p.y - d.start.y; + st.x = d.x0 + dx; + st.y = d.y0 + dy; + if (d.anchored0) { + try { applyAnchoredSnapshotDelta(d.anchored0, dx, dy); } catch {} + } + el.style.cursor = 'move'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + persistToGraphExtra(); + return; + } + } + + // When Annotate is OFF, allow moving a selected item only when sel override is enabled. + if (!state.enabled) { + if (!state.selOverride) return; + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const leftDown = (e.buttons & 1) === 1; + const sel = state.selection; + if (!sel || sel.kind !== 'select') { + if (!leftDown) el.style.cursor = 'default'; + return; + } + + if (!leftDown) { + if (sel.dragging || sel.pendingDrag) { + sel.dragging = false; + sel.pendingDrag = false; + sel.dragStart = null; + sel.dragData = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app?.canvas?.setDirty(true, true); + } else { + el.style.cursor = sel?.bbox && pointInRect(p, sel.bbox) ? 'move' : 'default'; + } + return; + } + + // Start dragging only after a small threshold (prevents click from becoming a drag) + if (!sel.dragging && sel.pendingDrag && sel.dragStart) { + const dx0 = p.x - sel.dragStart.x; + const dy0 = p.y - sel.dragStart.y; + if (Math.abs(dx0) > 2 || Math.abs(dy0) > 2) { + if (!sel._historyPushed) { + sel._historyPushed = true; + pushHistorySnapshot(); + } + sel.dragging = true; + sel.pendingDrag = false; + } + } + + if (sel.dragging && sel.dragStart && sel.dragData) { + const dx = p.x - sel.dragStart.x; + const dy = p.y - sel.dragStart.y; + for (const it of sel.dragData.paths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer?.locked) continue; + path.points = it.points0.map(pt => ({ x: pt.x + dx, y: pt.y + dy })); + } + for (const it of sel.dragData.stickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + st.x = it.x0 + dx; + st.y = it.y0 + dy; + } + try { if (sel.dragData.anchored0) applyAnchoredSnapshotDelta(sel.dragData.anchored0, dx, dy); } catch {} + if (sel.dragData.bbox0) { + sel.bbox = { + x0: sel.dragData.bbox0.x0 + dx, + y0: sel.dragData.bbox0.y0 + dy, + x1: sel.dragData.bbox0.x1 + dx, + y1: sel.dragData.bbox0.y1 + dy, + }; + } + } + + el.style.cursor = sel?.bbox && pointInRect(p, sel.bbox) ? 'move' : 'default'; + e.preventDefault(); + e.stopPropagation(); + app?.canvas?.setDirty(true, false); + return; + } + + // If a selection is pending (user pressed down but hasn't crossed threshold), + // consume events so the underlying workflow doesn't pan/move. + if (state.pendingSelection && state.activePointerId != null && e.pointerId === state.activePointerId) { + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + } + + // Sticker resize (transform tool) + if (state.stickerResize && state.activePointerId != null && e.pointerId === state.activePointerId) { + const d = state.stickerResize; + const st = state.stickers?.[d.stickerIdx]; + if (!st) return; + if (st.pinned) { + state.stickerResize = null; + } else { + if (!d._historyPushed) { + d._historyPushed = true; + pushHistorySnapshot(); + } + + const minSize = 5; + let x = d.x0, y = d.y0, w = d.w0, h = d.h0; + const dx = p.x - d.start.x; + const dy = p.y - d.start.y; + + if (d.corner === 'se') { w = d.w0 + dx; h = d.h0 + dy; } + if (d.corner === 'ne') { w = d.w0 + dx; h = d.h0 - dy; y = d.y0 + dy; } + if (d.corner === 'sw') { w = d.w0 - dx; h = d.h0 + dy; x = d.x0 + dx; } + if (d.corner === 'nw') { w = d.w0 - dx; h = d.h0 - dy; x = d.x0 + dx; y = d.y0 + dy; } + + if (state.transformMode === 'fixed') { + const asp = d.aspect || 1; + const ww = Math.abs(w); + const hh = Math.abs(h); + if (ww / Math.max(1e-6, hh) > asp) { + h = Math.sign(h || 1) * (ww / asp); + } else { + w = Math.sign(w || 1) * (hh * asp); + } + // Re-anchor based on corner + if (d.corner === 'ne') y = (d.y0 + d.h0) - h; + if (d.corner === 'sw') x = (d.x0 + d.w0) - w; + if (d.corner === 'nw') { x = (d.x0 + d.w0) - w; y = (d.y0 + d.h0) - h; } + } + + w = Math.max(minSize, w); + h = Math.max(minSize, h); + st.x = x; + st.y = y; + st.w = w; + st.h = h; + + // If this is an image sticker (screenshot), scale anchored children with it + if (d.anchored0 && isImageSticker(st)) { + try { + const parent0 = { x: d.x0, y: d.y0, w: d.w0, h: d.h0, rot: (typeof d.rot0 === 'number') ? d.rot0 : ((typeof st.rot === 'number') ? st.rot : 0) }; + const parent1 = { x, y, w, h, rot: parent0.rot }; + applyAnchoredSnapshotStickerResize(d.anchored0, parent0, parent1); + } catch {} + } + el.style.cursor = 'nwse-resize'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + persistToGraphExtra(); + return; + } + } + + // Transform selected items resize + if (state.transformDrag && state.activePointerId != null && e.pointerId === state.activePointerId) { + const d = state.transformDrag; + if (!d._historyPushed) { + d._historyPushed = true; + pushHistorySnapshot(); + } + const b0 = d.bbox0; + const anchor = { + x: (d.corner === 'nw' || d.corner === 'sw') ? b0.x1 : b0.x0, + y: (d.corner === 'nw' || d.corner === 'ne') ? b0.y1 : b0.y0, + }; + const cur = { x: p.x, y: p.y }; + let w = Math.max(1e-6, Math.abs(cur.x - anchor.x)); + let h = Math.max(1e-6, Math.abs(cur.y - anchor.y)); + if (state.transformMode === 'fixed') { + const asp = d.aspect || 1; + if (w / h > asp) h = w / asp; else w = h * asp; + } + const x0 = (cur.x < anchor.x) ? (anchor.x - w) : anchor.x; + const y0 = (cur.y < anchor.y) ? (anchor.y - h) : anchor.y; + const x1 = x0 + w; + const y1 = y0 + h; + + const sx = w / Math.max(1e-6, (b0.x1 - b0.x0)); + const sy = h / Math.max(1e-6, (b0.y1 - b0.y0)); + for (const it of d.items.paths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer?.locked) continue; + path.points = it.points0.map(pt => ({ + x: x0 + (pt.x - b0.x0) * sx, + y: y0 + (pt.y - b0.y0) * sy, + })); + } + for (const it of d.items.stickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + st.x = x0 + (it.x0 - b0.x0) * sx; + st.y = y0 + (it.y0 - b0.y0) * sy; + st.w = Math.max(5, it.w0 * sx); + st.h = Math.max(5, it.h0 * sy); + } + // Update selection bbox live + if (state.selection?.bbox) state.selection.bbox = { x0, y0, x1, y1 }; + + // Refresh flat paths + state.paths = []; + for (const lyr of state.layers) for (const pp of (lyr.paths || [])) state.paths.push(pp); + + el.style.cursor = 'nwse-resize'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + persistToGraphExtra(); + return; + } + + // Rotate selected items (stickers and/or paths) + if (state.rotateDrag && state.activePointerId != null && e.pointerId === state.activePointerId) { + const d = state.rotateDrag; + if (!d._historyPushed) { + d._historyPushed = true; + pushHistorySnapshot(); + } + const pivot = d.pivot; + const a0 = d.startAngle || 0; + const a1 = Math.atan2(p.y - pivot.y, p.x - pivot.x); + const da = a1 - a0; + const c = Math.cos(da); + const s = Math.sin(da); + + // Rotate paths + for (const it of d.items.paths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer?.locked) continue; + path.points = it.points0.map(pt => { + const dx = pt.x - pivot.x; + const dy = pt.y - pivot.y; + return { + x: pivot.x + (dx * c - dy * s), + y: pivot.y + (dx * s + dy * c), + }; + }); + } + + // Rotate stickers (around pivot, keep w/h) + for (const it of d.items.stickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + const w0 = (typeof it.w0 === 'number') ? it.w0 : st.w; + const h0 = (typeof it.h0 === 'number') ? it.h0 : st.h; + const cx0 = (typeof it.x0 === 'number' ? it.x0 : st.x) + w0 / 2; + const cy0 = (typeof it.y0 === 'number' ? it.y0 : st.y) + h0 / 2; + const dx = cx0 - pivot.x; + const dy = cy0 - pivot.y; + const cx1 = pivot.x + (dx * c - dy * s); + const cy1 = pivot.y + (dx * s + dy * c); + st.x = cx1 - w0 / 2; + st.y = cy1 - h0 / 2; + st.w = w0; + st.h = h0; + const rot0 = (typeof it.rot0 === 'number') ? it.rot0 : ((typeof st.rot === 'number') ? st.rot : 0); + st.rot = rot0 + da; + } + + // Update selection bbox live (axis-aligned bbox union) + if (state.selection) { + let bb = null; + for (const it of d.items.paths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + const pb = pathBBox(path); + if (pb) bb = bboxUnion(bb, pb); + } + for (const it of d.items.stickers || []) { + const st = state.stickers?.[it.stickerIdx]; + const sb = stickerBBox(st); + if (sb) bb = bboxUnion(bb, sb); + } + state.selection.bbox = bb; + } + + // Refresh flat paths + state.paths = []; + for (const lyr of state.layers) for (const pp of (lyr.paths || [])) state.paths.push(pp); + + el.style.cursor = 'grabbing'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + persistToGraphExtra(); + return; + } + + if (state.tool === 'select' || state.tool === 'transform' || state.tool === 'rotate') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const leftDown = (e.buttons & 1) === 1; + + // If we have a pending selection (started on pointerdown), create it only after real drag. + if (state.pendingSelection && leftDown) { + const ps = state.pendingSelection; + const thr = movementThresholdGraph(canvas, 8); + const dx0 = p.x - ps.start.x; + const dy0 = p.y - ps.start.y; + if (Math.abs(dx0) > thr || Math.abs(dy0) > thr) { + state.selection = { + kind: 'select', + mode: ps.mode, + start: { x: ps.start.x, y: ps.start.y }, + points: (ps.mode === 'lasso') ? [{ x: ps.start.x, y: ps.start.y }, { x: p.x, y: p.y }] : [{ x: ps.start.x, y: ps.start.y }], + rect: (ps.mode === 'rect') ? { x0: ps.start.x, y0: ps.start.y, x1: p.x, y1: p.y } : null, + selectedPaths: [], + selectedStickers: [], + bbox: null, + dragging: false, + pendingDrag: false, + creating: true, + }; + state.pendingSelection = null; + } + } + + const sel = state.selection; + if (!sel || sel.kind !== 'select') { + if (!leftDown) el.style.cursor = 'default'; + return; + } + + if (!leftDown) { + // Defensive: if pointerup was missed, ensure we stop moving on release + if (sel.dragging || sel.pendingDrag) { + sel.dragging = false; + sel.pendingDrag = false; + sel.dragStart = null; + sel.dragData = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + } else { + el.style.cursor = sel?.bbox && pointInRect(p, sel.bbox) ? 'move' : 'default'; + } + return; + } + + // Start dragging only after a small threshold (prevents click from becoming a drag) + if (!sel.dragging && sel.pendingDrag && sel.dragStart) { + const dx0 = p.x - sel.dragStart.x; + const dy0 = p.y - sel.dragStart.y; + if (Math.abs(dx0) > 2 || Math.abs(dy0) > 2) { + if (!sel._historyPushed) { + sel._historyPushed = true; + pushHistorySnapshot(); + } + sel.dragging = true; + sel.pendingDrag = false; + } + } + + if (sel.dragging && sel.dragStart && sel.dragData) { + const dx = p.x - sel.dragStart.x; + const dy = p.y - sel.dragStart.y; + // Move selected paths + for (const it of sel.dragData.paths || []) { + const layer = state.layers[it.layerIdx]; + const path = layer?.paths?.[it.pathIdx]; + if (!path || layer.locked) continue; + path.points = it.points0.map(pt => ({ x: pt.x + dx, y: pt.y + dy })); + } + // Move stickers + for (const it of sel.dragData.stickers || []) { + const st = state.stickers?.[it.stickerIdx]; + if (!st) continue; + st.x = it.x0 + dx; + st.y = it.y0 + dy; + } + + // Move anchored children of moved image stickers (if not already selected) + try { if (sel.dragData.anchored0) applyAnchoredSnapshotDelta(sel.dragData.anchored0, dx, dy); } catch {} + // Update bbox + if (sel.dragData.bbox0) { + sel.bbox = { + x0: sel.dragData.bbox0.x0 + dx, + y0: sel.dragData.bbox0.y0 + dy, + x1: sel.dragData.bbox0.x1 + dx, + y1: sel.dragData.bbox0.y1 + dy, + }; + } + } else if (sel.creating) { + if (sel.mode === 'rect') { + sel.rect = { x0: sel.start.x, y0: sel.start.y, x1: p.x, y1: p.y }; + const res = selectionFromRect(sel.rect); + sel.selectedPaths = res.selectedPaths; + sel.selectedStickers = res.selectedStickers; + sel.bbox = res.bbox; + } else { + sel.points.push({ x: p.x, y: p.y }); + const res = selectionFromPolygon(sel.points); + sel.selectedPaths = res.selectedPaths; + sel.selectedStickers = res.selectedStickers; + sel.bbox = res.bbox; + } + } + el.style.cursor = sel?.bbox && pointInRect(p, sel.bbox) ? 'move' : (sel.creating ? 'crosshair' : 'default'); + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + return; + } + + if (state.tool === 'screenshot') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const leftDown = (e.buttons & 1) === 1; + if (!leftDown) { + el.style.cursor = 'default'; + return; + } + const sel = state.selection; + if (!sel || sel.kind !== 'screenshot') return; + sel.rect = { x0: sel.start.x, y0: sel.start.y, x1: p.x, y1: p.y }; + sel.bbox = normalizeRect(sel.rect); + el.style.cursor = 'crosshair'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + return; + } + + if (state.tool === 'text') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const sel = state.selection; + if (!sel || sel.kind !== 'text') return; + sel.rect = { x0: sel.start.x, y0: sel.start.y, x1: p.x, y1: p.y }; + sel.bbox = normalizeRect(sel.rect); + el.style.cursor = 'crosshair'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + return; + } + + // Draw tool move + if (!state.current) return; + if (state.penOnly && state.activePointerId != null && e.pointerId !== state.activePointerId) return; state.current.points.push(p); // Change cursor to pen while drawing el.style.cursor = state.eraser ? 'cell' : 'crosshair'; @@ -1645,7 +7204,308 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }; const onUp = (e) => { // Release pointer capture if active - try { if (e && typeof el.releasePointerCapture === 'function') el.releasePointerCapture(e.pointerId); } catch {} + try { if (e && typeof e.pointerId === 'number' && typeof el.releasePointerCapture === 'function') el.releasePointerCapture(e.pointerId); } catch {} + + // End sticker drag/resize/transform/rotate + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId === state.activePointerId) { + if (state.stickerDrag) { + state.stickerDrag = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + persistToGraphExtra(true); + return; + } + if (state.stickerResize) { + state.stickerResize = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + persistToGraphExtra(true); + return; + } + if (state.transformDrag) { + state.transformDrag = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + persistToGraphExtra(true); + return; + } + if (state.rotateDrag) { + state.rotateDrag = null; + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + persistToGraphExtra(true); + return; + } + } + + // When Annotate is OFF, only finalize selection moves when sel override is enabled. + if (!state.enabled) { + if (!state.selOverride) { + state.pendingSelection = null; + state.activePointerId = null; + el.style.cursor = 'default'; + return; + } + + state.pendingSelection = null; + const sel = state.selection; + if (sel?.kind === 'select' && (sel.dragging || sel.pendingDrag)) { + sel.dragging = false; + sel.pendingDrag = false; + sel.dragStart = null; + sel.dragData = null; + state.activePointerId = null; + el.style.cursor = 'default'; + // Refresh flat paths (in case we moved something) + state.paths = []; + for (const lyr of state.layers) for (const p of (lyr.paths || [])) state.paths.push(p); + app?.canvas?.setDirty(true, true); + persistToGraphExtra(true); + return; + } + + state.activePointerId = null; + el.style.cursor = 'default'; + app?.canvas?.setDirty(true, true); + return; + } + + if (state.tool === 'select' || state.tool === 'transform' || state.tool === 'rotate') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const sel = state.selection; + + // If we had a pending selection, create it now ONLY if drag distance exceeded threshold. + try { + if (state.pendingSelection) { + const ps = state.pendingSelection; + const pUp = toGraphPos(e, canvas); + if (pUp) { + const thr = movementThresholdGraph(canvas, 8); + const dx0 = pUp.x - ps.start.x; + const dy0 = pUp.y - ps.start.y; + if (Math.abs(dx0) > thr || Math.abs(dy0) > thr) { + const tmpSel = { + kind: 'select', + mode: ps.mode, + start: { x: ps.start.x, y: ps.start.y }, + points: (ps.mode === 'lasso') ? [{ x: ps.start.x, y: ps.start.y }, { x: pUp.x, y: pUp.y }] : [{ x: ps.start.x, y: ps.start.y }], + rect: (ps.mode === 'rect') ? { x0: ps.start.x, y0: ps.start.y, x1: pUp.x, y1: pUp.y } : null, + selectedPaths: [], + selectedStickers: [], + bbox: null, + dragging: false, + pendingDrag: false, + creating: true, + }; + if (tmpSel.mode === 'rect' && tmpSel.rect) { + const res = selectionFromRect(tmpSel.rect); + tmpSel.selectedPaths = res.selectedPaths; + tmpSel.selectedStickers = res.selectedStickers; + tmpSel.bbox = res.bbox; + } else { + const res = selectionFromPolygon(tmpSel.points); + tmpSel.selectedPaths = res.selectedPaths; + tmpSel.selectedStickers = res.selectedStickers; + tmpSel.bbox = res.bbox; + } + tmpSel.creating = false; + state.selection = tmpSel; + } + } + state.pendingSelection = null; + } + } catch { + state.pendingSelection = null; + } + + if (sel?.kind === 'select' && (sel.dragging || sel.pendingDrag)) { + sel.dragging = false; + sel.pendingDrag = false; + sel.dragStart = null; + sel.dragData = null; + state.activePointerId = null; + el.style.cursor = 'default'; + // Refresh flat paths (in case we moved something) + state.paths = []; + for (const lyr of state.layers) for (const p of (lyr.paths || [])) state.paths.push(p); + // Requirement: after move the selection frame should disappear (only Select tool) + if (state.tool === 'select') clearSelection(); + app.canvas.setDirty(true, true); + persistToGraphExtra(true); + return; + } + + // Stop live selection creation on release. + if (sel?.kind === 'select' && sel.creating) { + sel.creating = false; + } + + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'screenshot') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const sel = state.selection; + const rectGraph = sel?.rect; + const rectN = normalizeRect(rectGraph); + if (!rectN || rectN.w < 5 || rectN.h < 5) { + clearSelection(); + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + return; + } + + const newShotId = newStickerId(); + + // Capture on next frame to maximize chance the canvas is up-to-date + const rectPx = graphRectToCanvasPxRect(rectN, canvas); + requestAnimationFrame(() => { + try { + const dataUrl = cropCanvasToDataUrl(canvas?.canvas, rectPx); + if (!dataUrl) return; + const dataKey = stickerDataKeyForId(newShotId); + try { + if (dataKey) __idbSet(__IAMCCS_IDB_STORE_STICKERS, dataKey, dataUrl); + } catch {} + state.stickers = state.stickers || []; + state.stickers.push({ + id: newShotId, + x: rectN.x0, + y: rectN.y0, + w: rectN.w, + h: rectN.h, + rot: 0, + pinned: false, + kind: 'image', + dataUrl, + dataKey, + }); + + // UX: after capture, go back to Select tool and keep the new screenshot selected + try { setTool('select'); } catch {} + try { + const idx = getStickerIdxById(newShotId); + const st = idx >= 0 ? state.stickers?.[idx] : null; + const bb = stickerBBox(st); + if (idx >= 0 && bb) { + state.selection = { + kind: 'select', + mode: 'rect', + points: [], + rect: null, + bbox: bb, + selectedPaths: [], + selectedStickers: [{ stickerIdx: idx, bbox: bb }], + dragging: false, + }; + } + } catch {} + + // Flash feedback AFTER capture so it doesn't overexpose the screenshot + state.shotFlash = { t0: performance.now(), rect: { x0: rectN.x0, y0: rectN.y0, x1: rectN.x1, y1: rectN.y1 } }; + persistToGraphExtra(true); + app?.canvas?.setDirty(true, true); + } catch (err) { + console.warn('[IAMCCS] Screenshot capture failed:', err); + } + }); + + clearSelection(); + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + return; + } + + if (state.tool === 'text') { + if (state.activePointerId != null && typeof e.pointerId === 'number' && e.pointerId !== state.activePointerId) return; + const sel = state.selection; + const rectGraph = sel?.rect; + const rectN = normalizeRect(rectGraph); + if (!rectN || rectN.w < 8 || rectN.h < 8) { + clearSelection(); + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + return; + } + + // Create a new text sticker and start editing + pushHistorySnapshot(); + const id = newStickerId(); + state.stickers = state.stickers || []; + // Default text color comes from the current brush palette + state.textColor = String(state.color || state.textColor || '#111111'); + // Make new text size feel proportional to the dragged box + const boxDrivenFontSize = Math.max(10, Math.min(220, rectN.h * 0.65)); + + // If the text is created over a screenshot sticker, anchor it to that sticker + let parentStickerId = null; + try { + const c = { x: rectN.x0 + rectN.w * 0.5, y: rectN.y0 + rectN.h * 0.5 }; + const hitImg = hitTestImageStickerInside(c, canvas); + parentStickerId = (hitImg >= 0) ? (state.stickers?.[hitImg]?.id || null) : null; + } catch {} + state.stickers.push({ + id, + x: rectN.x0, + y: rectN.y0, + w: rectN.w, + h: rectN.h, + rot: 0, + pinned: false, + kind: 'text', + parentStickerId: parentStickerId || undefined, + text: '', + fontFamily: String(state.textFontFamily || 'Arial'), + fontSize: boxDrivenFontSize, + textColor: String(state.color || state.textColor || '#111111'), + fontWeight: String(state.textFontWeight || 'normal'), + fontStyle: String(state.textFontStyle || 'normal'), + underline: !!state.textUnderline, + }); + + // Create a real Text Layer entry linked to this text sticker + try { + const base = { ...getCurrentLayerStyle() }; + state.layers.push({ + name: nextTextLayerName(), + kind: 'text', + textStickerId: id, + visible: true, + locked: false, + paths: [], + style: { + color: String(state.color || base.color || '#111111'), + dashed: !!base.dashed, + widthDraw: typeof base.widthDraw === 'number' ? base.widthDraw : (state.widthDraw || 7), + widthErase: typeof base.widthErase === 'number' ? base.widthErase : (state.widthErase || 48), + opacityDraw: typeof base.opacityDraw === 'number' ? base.opacityDraw : (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0), + opacityErase: typeof base.opacityErase === 'number' ? base.opacityErase : (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0), + } + }); + setCurrentLayer(state.layers.length - 1); + try { syncTextLayerFromStateColor(state.currentLayerIdx); } catch {} + try { ui.renderLayersList?.(); } catch {} + } catch {} + persistToGraphExtra(true); + + clearSelection(); + state.activePointerId = null; + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + try { openTextEditorForStickerId(id, canvas); } catch {} + return; + } + if (!state.current) return; if (state.penOnly && state.activePointerId != null && e.pointerId !== state.activePointerId) return; const layer = getCurrentLayer(); @@ -1659,7 +7519,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Reset cursor after drawing el.style.cursor = 'default'; app.canvas.setDirty(true, true); - persistToGraphExtra(); + persistToGraphExtra(true); }; el.addEventListener('pointerdown', onDown, true); @@ -1667,6 +7527,7 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { // Prefer element-level pointerup with pointer capture; add window fallback el.addEventListener('pointerup', onUp, true); el.addEventListener('pointercancel', onUp, true); + el.addEventListener('lostpointercapture', onUp, true); window.addEventListener('pointerup', onUp, false); console.log('[IAMCCS] Hooks attached'); @@ -1678,6 +7539,8 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { app.registerExtension({ name: 'IAMCCS.Annotate', init() { + // Keep UI resilient to ComfyUI DOM resets + try { startUIWatchdog(); } catch {} // Defer creating any UI until the canvas exists (prevents top bar flash) // Keyboard shortcuts: Alt+A toggle, Esc cancella path corrente window.addEventListener('keydown', (e) => { @@ -1688,17 +7551,64 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { if (e.altKey && (e.key === 'a' || e.key === 'A')) { setEnabled(!state.enabled); e.preventDefault(); + } else if (state.enabled && e.altKey && !e.ctrlKey && !e.shiftKey) { + // Tool shortcuts + if (e.key === '1') { setTool('draw'); try { showToast('Draw enabled', { kind: 'info', ms: 900 }); } catch {} e.preventDefault(); } + else if (e.key === '2') { setTool('select'); try { showToast('Select enabled', { kind: 'info', ms: 900 }); } catch {} e.preventDefault(); } + else if (e.key === '3') { setTool('transform'); try { showToast('Transform enabled', { kind: 'info', ms: 900 }); } catch {} e.preventDefault(); } + else if (e.key === '4') { setTool('rotate'); try { showToast('Rotate enabled', { kind: 'info', ms: 900 }); } catch {} e.preventDefault(); } + else if (e.key === '5') { setTool('screenshot'); try { showToast('Screenshot tool enabled', { kind: 'info', ms: 1000 }); } catch {} e.preventDefault(); } + else if (e.key === 'p' || e.key === 'P') { + state.pinMode = !state.pinMode; + syncFlagsUI(); + try { showToast(state.pinMode ? 'Pin mode enabled' : 'Pin mode disabled', { kind: 'info', ms: 1200 }); } catch {} + app?.canvas?.setDirty(true, true); + e.preventDefault(); + } } else if (e.altKey && (e.key === 'd' || e.key === 'D')) { // Toggle eraser/draw with Alt+D setEraserMode(!state.eraser); console.log('[IAMCCS] Eraser:', state.eraser ? 'ON' : 'OFF'); + try { showToast(state.eraser ? 'Eraser enabled' : 'Eraser disabled', { kind: 'info', ms: 1100 }); } catch {} + try { syncToolsUI(); } catch {} e.preventDefault(); } else if (e.altKey && (e.key === 's' || e.key === 'S')) { // Toggle hide notes with Option+S (Alt+S on Windows) state.hidden = !state.hidden; if (ui.hiddenChk) ui.hiddenChk.checked = state.hidden; + try { syncFlagsUI(); } catch {} + try { showToast(state.hidden ? 'Hide notes enabled' : 'Hide notes disabled', { kind: 'info', ms: 1200 }); } catch {} app?.canvas?.setDirty(true, true); e.preventDefault(); + } else if (state.enabled && state.tool === 'select' && e.ctrlKey && !e.shiftKey && !e.altKey && (e.key === 'c' || e.key === 'C')) { + // Ctrl+C: Copy selection + const ok = copySelectionToClipboard(); + if (ok) console.log('[IAMCCS] Copied selection'); + try { showToast(ok ? 'Selection copied' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} + e.preventDefault(); + } else if (state.enabled && state.tool === 'select' && e.ctrlKey && !e.shiftKey && !e.altKey && (e.key === 'x' || e.key === 'X')) { + // Ctrl+X: Cut selection + const ok = copySelectionToClipboard({ clearAfter: false }); + if (ok) { + deleteSelection({ allowLocked: false }); + console.log('[IAMCCS] Cut selection'); + } + try { showToast(ok ? 'Selection cut' : 'Nothing selected', { kind: ok ? 'info' : 'warn', ms: 1000 }); } catch {} + e.preventDefault(); + } else if (state.enabled && state.tool === 'select' && e.ctrlKey && !e.shiftKey && !e.altKey && (e.key === 'v' || e.key === 'V')) { + // Ctrl+V: Paste selection + pasteClipboardAt(state.lastPointerGraphPos); + e.preventDefault(); + } else if ((state.enabled || state.selOverride) && (e.key === 'Delete' || e.key === 'Backspace')) { + // Delete/Backspace: delete current selection (any tool) + const sel = state.selection; + if (sel?.kind === 'select' && ((sel.selectedPaths?.length || 0) > 0 || (sel.selectedStickers?.length || 0) > 0)) { + const did = deleteSelection({ allowLocked: false }); + if (!did) { + showToast('Cannot delete: selection is locked or pinned', { kind: 'warn', ms: 1700 }); + } + e.preventDefault(); + } } else if (e.ctrlKey && !e.shiftKey && !e.altKey && (e.key === 'i' || e.key === 'I')) { // Ctrl+I: Import workflow (or annotations JSON) e.preventDefault(); @@ -1708,6 +7618,8 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { state.paths.push(state.current); state.current = null; app?.canvas?.setDirty(true, true); + } else if (state.selection) { + clearSelection(); } } }); @@ -1716,10 +7628,21 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const wait = () => { if (app?.canvas?.canvas) { console.log('[IAMCCS] Setup called - canvas ready'); + try { startUIWatchdog(); } catch {} + // Migrate older localStorage autosaves to IndexedDB to free quota without losing data. + // Fire-and-forget: we don't want to block ComfyUI startup. + try { migrateLegacyAutosaveLocalStorageToIdb({ removeAfter: true }); } catch {} + // Create button early (not only during draw) + try { + if (!ui.floating || !document.body.contains(ui.floating)) { + ensureFloatingToggle(); + state.uiShown = true; + } + } catch {} attachCanvasHooks(); // Fallback: create button after 1 second if not created by draw setTimeout(() => { - if (!state.uiShown) { + if (!state.uiShown || !ui.floating || !document.body.contains(ui.floating)) { console.log('[IAMCCS] Fallback: creating floating button...'); ensureFloatingToggle(); state.uiShown = true; @@ -1733,6 +7656,20 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { const orig = app.loadGraphData?.bind(app); if (typeof orig === 'function') { app.loadGraphData = async function(...args) { + // Track whether the workflow JSON being loaded contains IAMCCS annotations. + // This allows us to clear stale app.graph.extra fields that would otherwise + // cause texts/layers to appear copied into workflows that don't have them. + try { + const obj = args?.[0]; + const ex = (obj && typeof obj === 'object') ? obj.extra : null; + const has = !!(ex && (ex.iamccs_annotations || ex.iamccs_annotations_multi)); + window.__IAMCCS_LAST_LOADED_WORKFLOW = { + at: Date.now(), + hasIamccsAnnotations: has, + iamccs_annotations: ex ? (ex.iamccs_annotations || null) : null, + iamccs_annotations_multi: ex ? (ex.iamccs_annotations_multi || null) : null, + }; + } catch {} const res = await orig(...args); try { handleWorkflowChange(); } catch (e) { console.warn('[IAMCCS] handleWorkflowChange failed:', e); } return res; @@ -1751,6 +7688,9 @@ if (!window.IAMCCS_ANNOTATE_LOADED) { }, }); + // Prevent workflow-level annotations from hitchhiking on node copy/paste. + // This installs a LiteGraph clipboard filter and optional selection-only transfer. + try { installNodeClipboardHooks(); } catch {} console.log('[IAMCCS] Extension registered'); })(); } diff --git a/pyproject.toml b/pyproject.toml index 93520a8..16bf755 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "IAMCCS-Annotate" -version = "1.0.0" +version = "2.0.0" description = "Drawing and annotation tool for ComfyUI with layer support" readme = "README.md" license = {text = "MIT"} diff --git a/version.json b/version.json index 01fe551..b4ecef9 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "name": "iamccs-annotate", - "version": "1.0.0", + "version": "2.0.0", "author": "Carmine Cristallo Scalzi (IAMCCS)", "description": "drawing and annotation tool for ComfyUI." }