commit d41934220963357a70a99a750a9bdfd1f23e4cf4 Author: IAMCCS Date: Tue Nov 4 13:21:17 2025 +0100 Initial commit diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..cf3a759 --- /dev/null +++ b/INSTRUCTIONS.md @@ -0,0 +1,93 @@ +# IAMCCS Annotate — User Guide + +Version: 1.0.0 + +Coming soon: 2.0.0 + +A fast, reliable annotation layer for ComfyUI. Draw, erase, organize in layers, and save notes inside your workflow. + +## What’s included + +- Zero-offset drawing/erasing that sticks to the graph while you pan/zoom +- Floating button with context menu and a draggable dock panel when sidebar is missing +- 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 +- Hide/show all annotations quickly +- Export/import your entire workflow + annotations +- Undo/redo and copy/paste for layers +- Draw above or below nodes (foreground/background) +- Automatic save to workflow.extra + +## Quick start + +1. Launch ComfyUI and load/open a workflow. +2. Click the floating button “Annotate: OFF” to turn ON (turns green). +3. Draw with left mouse. Middle button still pans the canvas. +4. Right-click the floating button to open the context menu. + +## Shortcuts + +- Alt+A — Toggle annotation mode +- Alt+D — Toggle eraser/draw +- Alt+S — Hide/show all annotations +- Alt+F — Export workflow + notes (JSON) +- Ctrl+I — Import workflow + notes (JSON) + +Note: On macOS, Alt refers to Option. + +## Drawing options + +- Color: color picker +- Brush size: 1–48 px +- Opacity: 10–100% +- Dashed: enable dashed strokes +- Constant width: keeps the stroke width consistent on-screen regardless of zoom + - 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 + +## Layers + +- + Layer: adds a new layer and selects it +- Click name: selects the layer +- Double-click name: rename; Enter to save, Esc to cancel +- Eye icon: toggle visibility +- Lock icon: prevent edits on the layer +- X: delete layer (at least one layer is always kept) + +## Saving and persistence + +- Saves to workflow.extra.iamccs_annotations +- 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 + +## 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 +- Import JSON (menu): imports annotations into the current layer + +## Troubleshooting + +- Floating button doesn’t show + - Wait a moment; it appears on first canvas draw + - Restart ComfyUI if needed +- Eraser draws black instead of erasing + - Make sure you’re using the included extension version; erasing works against an off-screen layer +- Notes shift when zooming/panning + - Fixed: the plugin draws in graph-space and composes in screen-space with devicePixelRatio awareness +- Notes don’t load + - Verify the workflow JSON has `extra.iamccs_annotations` + - Ensure the menu toggle “Save into WF” is enabled if you want automatic saving + +## Author and License + +- Author: Carmine Cristallo Scalzi (IAMCCS) +- License: Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0) +- License URL: https://creativecommons.org/licenses/by-nd/4.0/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3574551 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0) + +You are free to: +- Share — copy and redistribute the material in any medium or format +- Use — for personal or commercial purposes + +Under the following terms: +- Attribution — You must give appropriate credit, provide a link to the license, + and indicate if changes were made. You may do so in any reasonable manner, + but not in any way that suggests the licensor endorses you or your use. +- No Derivatives — If you remix, transform, or build upon the material, + you may not distribute the modified material without explicit permission + from the author. + +Author: Carmine Cristallo Scalzi (IAMCCS) +License URL: https://creativecommons.org/licenses/by-nd/4.0/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e70b27 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# IAMCCS Annotate - ComfyUI Drawing Extension + + + +Version: 1.0.0 — Coming soon: 2.0.0 (many new features!) + +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) + +## Features + +✨ **Drawing Tools** +- Free-hand drawing with customizable brush size (1-48px) +- Eraser mode to remove strokes +- Color picker for custom drawing colors +- Opacity control (10-100%) +- Dashed line option for different line styles +- Constant width option (maintains width at any zoom level) + - HiDPI ×2 rendering toggle for crisper strokes + +🎨 **Layer System** +- Create unlimited layers for organized annotations +- Toggle layer visibility (eye icon) +- Lock/unlock layers to prevent accidental editing +- Rename layers inline (double-click the name) +- Delete layers (minimum 1 layer always kept) +- Auto-selects new layer as active + +📌 **State Management** +- Save annotations with workflow (automatic) +- 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) + +![Title](assets/annotat_3.png) + +- Draggable button (click to toggle, drag to move) +- Compact control panel in context menu +- Inline layer naming (no popups) +- Real-time visual feedback + +![Title](assets/annotate_2.png) + +🎯 **Keyboard Shortcuts** +| Shortcut | Action | +|----------|--------| +| **Alt+A** | Toggle annotation mode ON/OFF | +| **Alt+D** | Toggle eraser / draw mode | +| **Alt+S** | Toggle hide/show annotations | +| **Esc** | Cancel current stroke | + +## Installation + +1. Download or clone this extension into your ComfyUI custom_nodes folder: +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/IAMCCS/IAMCCS_annotate.git +``` + +2. Restart ComfyUI + +3. Look for the **"Annotate: OFF"** button in the bottom-left corner + +### If my work helped you, and you’d like to say thanks — grab me a coffee ☕ + + + Buy Me A Coffee + + +## Usage + +### Basic Drawing +1. Click the floating button to enable annotation mode (turns green) +2. Click and drag on the canvas to draw +3. Adjust brush size and color from the context menu (right-click button) +4. Click the button again to disable (turns red) + +### Using the Eraser +1. Press **Alt+D** or click the eraser button in the menu (🩹 Eraser) +2. Draw on the canvas to erase strokes +3. Press **Alt+D** again to return to draw mode (✏️ Draw) + +### Managing Layers +1. Click **"+ Layer"** button to create a new layer +2. **Click** on layer name to select it +3. **Double-click** layer name to rename it (press Enter to confirm, Esc to cancel) +4. Use **eye icon** to hide/show layer +5. Use **lock icon** to prevent editing +6. Use **X button** to delete layer + +### 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 + +## Context Menu Options + +Right-click the floating button to access: +- **Enable/Disable** - Quick toggle (also left-click button) +- **Color Picker** - Choose drawing color +- **Brush Size** - Adjust pen width (1-48px) +- **Opacity** - Control transparency (10-100%) +- **Drawing Modes**: + - ✏️ Draw / 🩹 Eraser toggle + - Constant width option + - Dashed lines option + - Hide notes option +- **Layer Management** - Create, rename, manage layers +- **Export/Import** - Save/load annotations separately +- **Save into Workflow** - Toggle auto-save feature + +## Tips & Tricks + +💡 **Pro Tips** +- Use **Alt+S** while working to hide annotations and see clean canvas +- Layer-based organization helps keep complex annotations organized +- Constant width mode is great for diagrams and technical annotations +- Export annotations as JSON for backup or sharing + +🎨 **Drawing Best Practices** +- Use different layers for different annotations (ideas, notes, corrections) +- Keep brush size reasonable for readability at normal zoom +- Lock completed layers to avoid accidental edits +- Use dashed lines to distinguish annotations from actual drawing + +🔄 **Workflow Tips** +- Annotations persist across ComfyUI sessions +- Copy workflow JSON to backup annotations +- Use hide toggle (Alt+S) for presentations +- Export layers separately for different annotations + + +## Troubleshooting + +**Button doesn't appear?** +- Restart ComfyUI +- Check browser console for errors (F12) +- Verify extension is in `custom_nodes/IAMCCS_annotate` + +**Annotations not saving?** +- Check "Save into workflow" toggle in menu +- Ensure workflow is saved to disk +- Check browser console for errors + +**Eraser not working?** +- Make sure you're in eraser mode (button shows 🩹) +- Try pressing Alt+D to toggle mode +- Check that layer is not locked + +**Layer issues?** +- At least one layer is always required +- Can't delete if it's the only layer +- Locked layers prevent drawing but still show + +**Keyboard Shortcuts Quick Reference:** +- 🖱️ **Left-click button** = Toggle ON/OFF +- 🖱️ **Right-click button** = Open menu +- 🖱️ **Drag button** = Move it around +- ⌨️ **Alt+A** = Toggle annotation +- ⌨️ **Alt+D** = Toggle eraser +- ⌨️ **Alt+S** = Toggle hide +- ⌨️ **Esc** = Cancel stroke + +## Credits + +**IAMCCS_annotate** - Drawing & annotation tool for ComfyUI +Minimal, compact, and powerful annotation system + + + ## License + +- License: Creative Commons Attribution-NoDerivatives 4.0 International (CC BY-ND 4.0) +- License URL: https://creativecommons.org/licenses/by-nd/4.0/ + +Found a bug or have a feature request? +- Check the GitHub issues +- Report problems with detailed description +- Include ComfyUI version and browser info + +--- diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..47ed3e4 --- /dev/null +++ b/__init__.py @@ -0,0 +1,13 @@ +""" +IAMCCS Annotate - ComfyUI Extension +A drawing annotation tool with floating button and context menu +""" + +# No node classes - this is purely a UI extension +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +# Tell ComfyUI where to find the web extension files +WEB_DIRECTORY = "js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/assets/annotat_3.png b/assets/annotat_3.png new file mode 100644 index 0000000..adfacab Binary files /dev/null and b/assets/annotat_3.png differ diff --git a/assets/annotate.png b/assets/annotate.png new file mode 100644 index 0000000..91cdc3d Binary files /dev/null and b/assets/annotate.png differ diff --git a/assets/annotate_1.png b/assets/annotate_1.png new file mode 100644 index 0000000..0e6d320 Binary files /dev/null and b/assets/annotate_1.png differ diff --git a/assets/annotate_2.png b/assets/annotate_2.png new file mode 100644 index 0000000..3edc2d4 Binary files /dev/null and b/assets/annotate_2.png differ diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..c73b6c8 Binary files /dev/null and b/icon.png differ diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..9e178ae --- /dev/null +++ b/js/index.js @@ -0,0 +1,1502 @@ +// IAMCCS Annotate — clean ComfyUI extension with floating button + context menu +console.log('[IAMCCS] Extension file loaded'); + +// Single-init guard to avoid duplicate loads +if (!window.IAMCCS_ANNOTATE_LOADED) { + console.log('[IAMCCS] Initializing...'); + window.IAMCCS_ANNOTATE_LOADED = true; + + (async () => { + // Import ComfyUI app (ES module) + let app; + try { + ({ app } = await import('/scripts/app.js')); + console.log('[IAMCCS] App imported successfully'); + } catch (e) { + console.error('[IAMCCS] Cannot import /scripts/app.js:', e); + return; + } + + const state = { + enabled: false, + color: '#ff4444', + width: 15, + paths: [], // {points:[{x,y}], color, width} - kept for backward compatibility + current: null, + saveWithWorkflow: true, + opacity: 1.0, + eraser: false, + constantScreen: false, + dashed: false, + hidden: false, + hiDPIx2: false, + // Per-mode memory for brush and opacity + widthDraw: 15, + widthErase: 48, + opacityDraw: 1.0, + opacityErase: 1.0, + // Layer system + currentLayerIdx: 0, + layers: [{ name: 'Layer 1', visible: true, locked: false, paths: [] }], + uiShown: false, + }; + + const ui = { + btn: null, // sidebar toggle button + floating: null, // floating toggle button (fallback/always available) + widthValue: null, // span for width value + opacityValue: null, + eraserBtn: null, + constantChk: null, + hiddenChk: null, // hide notes checkbox + contextMenu: null, + }; + + // Offscreen buffer so eraser only affects annotations, not the ComfyUI graph + let __annoCanvas = null; + let __annoCtx = null; + let __annoW = 0, __annoH = 0; + + function loadPos(key, fallback) { + try { + const s = localStorage.getItem(key); + if (!s) return fallback; + const v = JSON.parse(s); + if (typeof v?.left === 'number' && typeof v?.top === 'number') return v; + } catch {} + return fallback; + } + function savePos(key, pos) { + try { localStorage.setItem(key, JSON.stringify(pos)); } catch {} + } + function makeDraggable(el, { storageKey, handle, isFixed = true, onDrag, onDragStart }) { + const target = handle || el; + let dragging = false; + let startX = 0, startY = 0, startLeft = 0, startTop = 0; + let dragStarted = false; + const onDown = (e) => { + if (e.button !== 0) return; // left only + dragging = true; + dragStarted = false; + const rect = el.getBoundingClientRect(); + // Switch to top positioning for fixed elements to avoid bottom conflicts + if (isFixed) { + el.style.position = 'fixed'; + el.style.bottom = ''; + } + startX = e.clientX; startY = e.clientY; + startLeft = rect.left; startTop = rect.top; + window.addEventListener('pointermove', onMove, true); + window.addEventListener('pointerup', onUp, true); + e.preventDefault(); e.stopPropagation(); + }; + const onMove = (e) => { + if (!dragging) return; + const dx = e.clientX - startX; + const dy = e.clientY - startY; + // Only start dragging if movement > 5px (threshold for click vs drag) + if (!dragStarted && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { + dragStarted = true; + if (typeof onDragStart === 'function') onDragStart(); + } + if (!dragStarted) return; // Don't move until threshold is met + const left = Math.max(0, Math.min((isFixed ? window.innerWidth : document.body.clientWidth) - el.offsetWidth, startLeft + dx)); + const top = Math.max(0, Math.min((isFixed ? window.innerHeight : document.body.clientHeight) - el.offsetHeight, startTop + dy)); + el.style.left = left + 'px'; + el.style.top = top + 'px'; + savePos(storageKey, { left, top }); + try { if (typeof onDrag === 'function') onDrag({ left, top }); } catch {} + }; + const onUp = () => { + dragging = false; + window.removeEventListener('pointermove', onMove, true); + window.removeEventListener('pointerup', onUp, true); + }; + target.style.cursor = 'move'; + target.addEventListener('pointerdown', onDown, true); + } + + function syncUI() { + // Sidebar button + if (ui.btn) { + ui.btn.textContent = state.enabled ? 'ENABLED' : 'DISABLED'; + ui.btn.style.background = state.enabled ? '#4CAF50' : '#f44336'; + } + // 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'; + } + // Sync context menu if open + if (ui.contextMenu && ui.contextMenu.parentElement) { + const tgl = ui.contextMenu.querySelector('#ctx_toggle'); + if (tgl) { + tgl.textContent = state.enabled ? 'ENABLED' : 'DISABLED'; + tgl.style.background = state.enabled ? '#4CAF50' : '#f44336'; + } + } + } + + function setEnabled(val) { + state.enabled = !!val; + // If disabling while drawing, finish current stroke gracefully + if (!state.enabled && state.current) { + state.paths.push(state.current); + state.current = null; + } + // On enable, reset to required defaults (draw mode active) + if (state.enabled) { + state.color = '#ff4444'; + state.widthDraw = 15; + state.opacityDraw = 1.0; + state.widthErase = 48; + state.opacityErase = 1.0; + state.eraser = false; // start in draw mode + state.width = state.widthDraw; + state.opacity = state.opacityDraw; + if (ui.eraserBtn) { + ui.eraserBtn.textContent = '✏️ Draw'; + ui.eraserBtn.style.background = '#795548'; + } + syncBrushOpacityUI(); + } + syncUI(); + app?.canvas?.setDirty(true, true); + console.log('[IAMCCS] Annotate:', state.enabled ? 'ON' : 'OFF'); + } + + function syncBrushOpacityUI() { + // Sidebar/dock + const sec = document.getElementById('iamccs-sidebar'); + const wEl = sec?.querySelector('#iam_width'); + const wvEl = sec?.querySelector('#iam_wv'); + const opEl = sec?.querySelector('#iam_opacity'); + const ovEl = sec?.querySelector('#iam_ov'); + if (wEl) wEl.value = String(state.width); + if (wvEl) wvEl.textContent = String(state.width); + if (opEl) opEl.value = String(Math.round((state.opacity||1)*100)); + if (ovEl) ovEl.textContent = String(Math.round((state.opacity||1)*100)); + // Context menu, if open + if (ui.contextMenu) { + const cw = ui.contextMenu.querySelector('#ctx_width'); + const cwv = ui.contextMenu.querySelector('#ctx_wv'); + const cop = ui.contextMenu.querySelector('#ctx_opacity'); + const cov = ui.contextMenu.querySelector('#ctx_ov'); + if (cw) cw.value = String(state.width); + if (cwv) cwv.textContent = String(state.width); + if (cop) cop.value = String(Math.round((state.opacity||1)*100)); + if (cov) cov.textContent = String(Math.round((state.opacity||1)*100)); + } + } + + function setEraserMode(on) { + const next = !!on; + if (state.eraser === next) return; + if (next) { + // switching to eraser: remember current draw values, apply eraser values + state.widthDraw = state.width; + state.opacityDraw = state.opacity; + state.width = state.widthErase || state.width; + state.opacity = (typeof state.opacityErase === 'number') ? state.opacityErase : state.opacity; + } else { + // switching to draw: remember current eraser values, apply draw values + state.widthErase = state.width; + state.opacityErase = state.opacity; + state.width = state.widthDraw || state.width; + state.opacity = (typeof state.opacityDraw === 'number') ? state.opacityDraw : state.opacity; + } + state.eraser = next; + if (ui.eraserBtn) { + ui.eraserBtn.textContent = state.eraser ? '🩹 Eraser' : '✏️ Draw'; + ui.eraserBtn.style.background = state.eraser ? '#c2185b' : '#795548'; + } + syncBrushOpacityUI(); + app?.canvas?.setDirty(true, true); + } + + function findSidebarElement() { + // Try several known selectors; return the first match or null + const selectors = [ + '.sidebar', + '#sidebar', + '.left-panel', + '.left-sidebar', + '.comfyui-sidebar', + '.comfy-menu + div', + '[class*="sidebar"]', + ]; + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el) return el; + } + return null; + } + + function ensureSidebarSection() { + let section = document.getElementById('iamccs-sidebar'); + if (section) return section; + + const sidebar = findSidebarElement(); + if (!sidebar) { + // Do not create fallback here; we'll use a dock near the canvas instead + return null; + } + const container = sidebar; + + section = document.createElement('div'); + section.id = 'iamccs-sidebar'; + section.style.cssText = 'padding:10px;border-top:1px solid #444;background:rgba(0,0,0,0.25);margin:6px 0;'; + + section.innerHTML = ` +
+
🎨 Annotate
+
graph-locked
+
+
+ + +
+
+ +
+
Brush: 15px
+ +
+
+
+
Opacity: 100%
+ +
+
+ + + + + +
+
+
+ +
+ + +
+
IAMCCS_annotate · draw & note on ComfyUI
+ `; + + container.appendChild(section); + + // Wire events + ui.btn = section.querySelector('#iam_toggle'); + const clr = section.querySelector('#iam_clear'); + const color = section.querySelector('#iam_color'); + const width = section.querySelector('#iam_width'); + ui.widthValue = section.querySelector('#iam_wv'); + ui.opacityValue = section.querySelector('#iam_ov'); + const opacity = section.querySelector('#iam_opacity'); + ui.eraserBtn = section.querySelector('#iam_eraser'); + ui.constantChk = section.querySelector('#iam_constant'); + const dashedChk = section.querySelector('#iam_dashed'); + const hidpiChk = section.querySelector('#iam_hidpi'); + const hiddenChk = section.querySelector('#iam_hidden'); + 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); + }); + clr.addEventListener('click', () => { + // Clear all non-locked layers + for (const lyr of state.layers) if (!lyr.locked) lyr.paths = []; + // Refresh flat paths for backward compatibility + state.paths = []; + 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(); + }); + 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); + }); + 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)); + }); + ui.eraserBtn.addEventListener('click', () => { + setEraserMode(!state.eraser); + }); + ui.constantChk.addEventListener('change', () => { + state.constantScreen = !!ui.constantChk.checked; + app?.canvas?.setDirty(true, true); + }); + dashedChk.addEventListener('change', () => { + state.dashed = !!dashedChk.checked; + app?.canvas?.setDirty(true, true); + }); + hidpiChk.addEventListener('change', () => { + state.hiDPIx2 = !!hidpiChk.checked; + app?.canvas?.setDirty(true, true); + }); + hiddenChk.addEventListener('change', () => { + state.hidden = !!hiddenChk.checked; + app?.canvas?.setDirty(true, true); + }); + saveChk?.addEventListener('change', () => { + state.saveWithWorkflow = !!saveChk.checked; + if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); + }); + saveNow?.addEventListener('click', () => { + persistToGraphExtra(true); + }); + loadNow?.addEventListener('click', () => { + const loaded = loadFromGraphExtra(); + if (!loaded) console.warn('[IAMCCS] Nessuna annotazione trovata in workflow.extra'); + }); + + // If we mounted to body (fallback), try to re-parent to real sidebar when it appears + if (!sidebar) { + let tries = 0; + const reparent = () => { + const sb = findSidebarElement(); + if (sb) { + sb.appendChild(section); + section.style.cssText = 'padding:10px;border-top:1px solid #444;background:rgba(0,0,0,0.25);margin:6px 0;'; + console.log('[IAMCCS] Sidebar found later — UI moved into sidebar'); + return; + } + if (tries++ < 20) setTimeout(reparent, 500); + }; + setTimeout(reparent, 500); + } + + // Initial UI state + syncUI(); + syncBrushOpacityUI(); + return section; + } + + function ensureCanvasDock() { + // Create or reposition the panel next to the canvas when real sidebar is unavailable + const section = document.getElementById('iamccs-sidebar'); + const canvasEl = app?.canvas?.canvas; + if (!canvasEl) return null; + const host = canvasEl.parentElement || document.body; + let panel = section; + if (!panel) { + // Build a new panel identical to sidebar section (English, full controls) + panel = document.createElement('div'); + panel.id = 'iamccs-sidebar'; + panel.innerHTML = ` +
+
🎨 Annotate
+
graph-locked
+
+
+ + +
+
+ +
+
Brush: 15px
+ +
+
+
+
Opacity: 100%
+ +
+
+ + + + + +
+
+
+ +
+ + +
+
IAMCCS_annotate · draw & note on ComfyUI
+ `; + // Wire events (same as sidebar) + ui.btn = panel.querySelector('#iam_toggle'); + const clr = panel.querySelector('#iam_clear'); + const color = panel.querySelector('#iam_color'); + const width = panel.querySelector('#iam_width'); + const opacity = panel.querySelector('#iam_opacity'); + ui.widthValue = panel.querySelector('#iam_wv'); + ui.opacityValue = panel.querySelector('#iam_ov'); + ui.eraserBtn = panel.querySelector('#iam_eraser'); + ui.constantChk = panel.querySelector('#iam_constant'); + const dashedChk = panel.querySelector('#iam_dashed'); + const hidpiChk = panel.querySelector('#iam_hidpi'); + const hiddenChk = panel.querySelector('#iam_hidden'); + 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)); + clr.addEventListener('click', () => { + // Clear all non-locked layers + for (const lyr of state.layers) if (!lyr.locked) lyr.paths = []; + // Refresh flat paths for backward compatibility + state.paths = []; + 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(); + }); + 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); + }); + 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)); + }); + ui.eraserBtn.addEventListener('click', () => { + setEraserMode(!state.eraser); + }); + ui.constantChk.addEventListener('change', () => { + state.constantScreen = !!ui.constantChk.checked; + app?.canvas?.setDirty(true, true); + }); + dashedChk.addEventListener('change', () => { + state.dashed = !!dashedChk.checked; + app?.canvas?.setDirty(true, true); + }); + hidpiChk.addEventListener('change', () => { + state.hiDPIx2 = !!hidpiChk.checked; + app?.canvas?.setDirty(true, true); + }); + hiddenChk.addEventListener('change', () => { + state.hidden = !!hiddenChk.checked; + app?.canvas?.setDirty(true, true); + }); + saveChk?.addEventListener('change', () => { + state.saveWithWorkflow = !!saveChk.checked; + if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); + }); + saveNow?.addEventListener('click', () => persistToGraphExtra(true)); + loadNow?.addEventListener('click', () => { + const loaded = loadFromGraphExtra(); + if (!loaded) console.warn('[IAMCCS] Nessuna annotazione trovata in workflow.extra'); + }); + } + + // Style as dock next to canvas + panel.style.position = 'absolute'; + // restored position if saved + const saved = loadPos('iamccs_dock_pos', { left: 180, top: 80 }); + panel.style.left = saved.left + 'px'; + panel.style.top = saved.top + 'px'; + panel.style.width = '220px'; + panel.style.background = 'rgba(0,0,0,0.9)'; + panel.style.border = '1px solid #444'; + panel.style.borderRadius = '8px'; + panel.style.padding = '12px'; + panel.style.color = '#fff'; + panel.style.zIndex = '10'; + panel.style.fontFamily = 'Segoe UI, Arial'; + + if (panel.parentElement !== host) host.appendChild(panel); + // Make panel draggable by its header + const header = panel.firstElementChild; + makeDraggable(panel, { storageKey: 'iamccs_dock_pos', handle: header, isFixed: false }); + syncUI(); + syncBrushOpacityUI(); + // Hide floating toggle if dock is visible + if (ui.floating) ui.floating.style.display = 'none'; + return panel; + } + + function persistToGraphExtra(force = false) { + if (!app?.graph) return; + if (!state.saveWithWorkflow && !force) return; + 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, + }; + 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; + if (!data) return false; + + state.color = data.color || state.color; + state.width = data.width || state.width; + + // 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', + visible: layer.visible !== false, + locked: !!layer.locked, + paths: Array.isArray(layer.paths) ? layer.paths.map(p => ({ + color: p.color || '#ff4444', + width: p.width || 3, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + mode: p.mode === 'erase' ? 'erase' : 'draw', + dashed: !!p.dashed, + points: Array.isArray(p.points) ? p.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + })) : [], + })); + state.currentLayerIdx = typeof data.currentLayerIdx === 'number' ? data.currentLayerIdx : 0; + } else if (Array.isArray(data.paths)) { + // Fallback to v1 format: migrate paths into layers + state.paths = data.paths.map(p => ({ + color: p.color || '#ff4444', + width: p.width || 3, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + mode: p.mode === 'erase' ? 'erase' : 'draw', + dashed: !!p.dashed, + points: Array.isArray(p.points) ? p.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + })); + // Also populate first layer + if (state.layers[0]) state.layers[0].paths = [...state.paths]; + } else { + return false; + } + + app?.canvas?.setDirty(true, true); + console.log('[IAMCCS] Annotazioni caricate da workflow.extra'); + return true; + } + + function exportAnnotations() { + try { + // Include current in-progress stroke if any (without mutating state) + const paths = state.current ? [...state.paths, state.current] : state.paths; + // Serialize full workflow (if available) and inject annotations into extra + let wf = null; + try { wf = app?.graph?.serialize ? app.graph.serialize() : null; } catch {} + if (!wf) wf = { last_node_id: undefined, last_link_id: undefined, nodes: [], links: [], groups: [], config: {}, extra: {} }; + wf.extra = wf.extra || {}; + wf.extra.iamccs_annotations = { version: 1, color: state.color, width: state.width, paths }; + const blob = new Blob([JSON.stringify(wf, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + const date = new Date().toISOString().replace(/[:.]/g, '-'); + a.href = url; + a.download = `workflow-with-annotations-${date}.json`; + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0); + } catch (e) { + console.error('[IAMCCS] Export failed:', e); + } + } + + function importAnnotations(file) { + try { + const reader = new FileReader(); + reader.onload = () => { + try { + const data = JSON.parse(String(reader.result||'{}')); + const ann = (data && data.extra && data.extra.iamccs_annotations) ? data.extra.iamccs_annotations : data; + const paths = Array.isArray(ann) ? ann : (Array.isArray(ann.paths) ? ann.paths : []); + state.paths = paths.map(p => ({ + color: p.color || state.color, + width: p.width || state.width, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + mode: p.mode === 'erase' ? 'erase' : 'draw', + dashed: !!p.dashed, + points: Array.isArray(p.points) ? p.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + })); + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + console.log('[IAMCCS] Annotations imported:', state.paths.length); + } catch (err) { + console.error('[IAMCCS] Invalid JSON for import:', err); + } + }; + reader.readAsText(file); + } catch (e) { + console.error('[IAMCCS] Import failed:', e); + } + } + + // Import workflow or annotations from an object + async function importFromObject(obj) { + try { + // Workflow JSON usually has nodes array + if (obj && Array.isArray(obj.nodes)) { + if (typeof app?.loadGraphData === 'function') { + await app.loadGraphData(obj); + // handleWorkflowChange will run via our wrapped loadGraphData + console.log('[IAMCCS] Workflow imported'); + return true; + } else { + console.warn('[IAMCCS] app.loadGraphData is not available'); + return false; + } + } + // Otherwise, try to import annotations only + const ann = (obj && obj.extra && obj.extra.iamccs_annotations) ? obj.extra.iamccs_annotations : obj; + const paths = Array.isArray(ann) ? ann : (Array.isArray(ann?.paths) ? ann.paths : []); + if (paths && Array.isArray(paths)) { + state.paths = paths.map(p => ({ + color: p.color || state.color, + width: p.width || state.width, + opacity: typeof p.opacity === 'number' ? p.opacity : 1, + mode: p.mode === 'erase' ? 'erase' : 'draw', + dashed: !!p.dashed, + points: Array.isArray(p.points) ? p.points.map(pt => ({ x: pt.x, y: pt.y })) : [], + })); + // Put into the first layer for compatibility + if (state.layers[0]) state.layers[0].paths = [...state.paths]; + app?.canvas?.setDirty(true, true); + if (state.saveWithWorkflow) persistToGraphExtra(true); + console.log('[IAMCCS] Annotations imported (from object):', state.paths.length); + return true; + } + } catch (e) { + console.error('[IAMCCS] Import from object failed:', e); + } + return false; + } + + // Open file picker and import workflow (Ctrl+I) + function promptImportWorkflow() { + try { + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = 'application/json,.json'; + inp.onchange = async () => { + try { + const f = inp.files && inp.files[0]; + if (!f) return; + const reader = new FileReader(); + reader.onload = async () => { + try { + const obj = JSON.parse(String(reader.result||'{}')); + await importFromObject(obj); + } catch (e) { + console.error('[IAMCCS] Invalid workflow JSON:', e); + } + }; + reader.readAsText(f); + } catch (e) { + console.error('[IAMCCS] Could not import workflow:', e); + } + }; + inp.click(); + } catch (e) { + console.error('[IAMCCS] Could not open file picker:', e); + } + } + + // Expose a minimal bridge for workflow change handling + try { + window.IAMCCS_ANNOTATE_MODULE = { + _handleWorkflowChange() { + // If the new graph contains annotations, load them; otherwise clear local state + const loaded = loadFromGraphExtra(); + if (!loaded) { + // Reset to default state with single empty layer + state.paths = []; + state.current = null; + state.currentLayerIdx = 0; + state.layers = [{ name: 'Layer 1', visible: true, locked: false, paths: [] }]; + app?.canvas?.setDirty(true, true); + } + } + }; + } catch {} + + // Layer management functions + function getCurrentLayer() { + return state.layers[state.currentLayerIdx]; + } + function addLayer() { + const layerNum = state.layers.length + 1; + state.layers.push({ name: `Layer ${layerNum}`, visible: true, locked: false, paths: [] }); + state.currentLayerIdx = state.layers.length - 1; + app?.canvas?.setDirty(true, true); + return state.layers.length - 1; + } + function deleteLayer(idx) { + if (state.layers.length <= 1) return false; // Keep at least one layer + state.layers.splice(idx, 1); + if (state.currentLayerIdx >= state.layers.length) state.currentLayerIdx = state.layers.length - 1; + app?.canvas?.setDirty(true, true); + return true; + } + function toggleLayerVisibility(idx) { + if (state.layers[idx]) state.layers[idx].visible = !state.layers[idx].visible; + app?.canvas?.setDirty(true, true); + } + function toggleLayerLock(idx) { + if (state.layers[idx]) state.layers[idx].locked = !state.layers[idx].locked; + } + function setCurrentLayer(idx) { + if (idx >= 0 && idx < state.layers.length) { + state.currentLayerIdx = idx; + app?.canvas?.setDirty(true, true); + } + } + + function ensureFloatingToggle() { + if (ui.floating && document.body.contains(ui.floating)) return ui.floating; + const btn = document.createElement('button'); + btn.id = 'iamccs-floating-toggle'; + btn.textContent = state.enabled ? 'Annotate: ON' : 'Annotate: OFF'; + const saved = loadPos('iamccs_float_pos', { left: 180, top: null }); + btn.style.cssText = [ + 'position:fixed', + (saved.top == null ? 'bottom:12px' : ('top:' + Math.max(60, Math.min(window.innerHeight - 40, saved.top)) + 'px')), + 'left:' + Math.max(0, Math.min(window.innerWidth - 120, saved.left)) + 'px', + 'padding:8px 12px', + 'font-weight:600', + 'font-size:12px', + 'color:#fff', + 'background:' + (state.enabled ? '#2e7d32' : '#9e2b25'), + 'border:1px solid ' + (state.enabled ? '#66bb6a' : '#ef5350'), + 'border-radius:8px', + 'box-shadow:0 2px 8px rgba(0,0,0,0.35)', + 'z-index:2000', + 'cursor:pointer', + ].join(';'); + // Track if button was dragged (to avoid toggle on drag) + let btnDragging = false; + btn.addEventListener('pointerdown', () => { btnDragging = false; }, true); + btn.addEventListener('click', (e) => { + // Only toggle if not dragging + if (!btnDragging) { + e.preventDefault(); + e.stopPropagation(); + setEnabled(!state.enabled); + } + }, true); + // Right-click context menu with options + btn.addEventListener('contextmenu', (e) => { + e.preventDefault(); + showContextMenu(e.clientX, e.clientY, btn); + }); + document.body.appendChild(btn); + ui.floating = btn; + // Make draggable (whole button) and move menu with it if open + makeDraggable(btn, { + storageKey: 'iamccs_float_pos', + isFixed: true, + onDragStart: () => { btnDragging = true; }, // Set flag when drag starts + onDrag: ({ left, top }) => { + if (ui.contextMenu && ui.contextAnchor && ui.contextAnchor.anchor === btn) { + const nx = Math.max(0, Math.min(window.innerWidth - ui.contextMenu.offsetWidth, left + ui.contextAnchor.dx)); + const ny = Math.max(0, Math.min(window.innerHeight - ui.contextMenu.offsetHeight, top + ui.contextAnchor.dy)); + ui.contextMenu.style.left = nx + 'px'; + ui.contextMenu.style.top = ny + 'px'; + } + } + }); + return btn; + } + + function showContextMenu(x, y, anchorEl) { + // Remove existing + if (ui.contextMenu && document.body.contains(ui.contextMenu)) ui.contextMenu.remove(); + const menu = document.createElement('div'); + menu.id = 'iamccs-context-menu'; + menu.style.cssText = [ + 'position:fixed', + // left/top set after append so we can clamp using size + 'min-width:220px', + 'background:rgba(0,0,0,0.95)', + 'border:1px solid #444', + 'border-radius:8px', + 'padding:10px', + 'color:#fff', + 'z-index:3000', + 'font-family:Segoe UI, Arial', + 'box-shadow:0 4px 16px rgba(0,0,0,0.4)' + ].join(';'); + menu.innerHTML = ` +
IAMCCS_annotate - Options
+
+ + +
+
+ +
+
Brush: ${state.width}px
+ +
+
+
+
Opacity: ${Math.round(state.opacity*100)}%
+ +
+
+ + + + + + +
+
+
+
Layers
+ +
+
+
+
+
IAMCCS_annotate · draw & note on ComfyUI
+
+ + + +
+
+ `; + document.body.appendChild(menu); + + // Function to render layers list + function renderLayersList() { + const list = menu.querySelector('#ctx_layers_list'); + if (!list) return; + // Timer to allow double-click to cancel the single-click re-render + let nameClickTimer = null; + // 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(); + list.innerHTML = order.map((idx) => { + const layer = state.layers[idx]; + return ` +
+ + + + +
`; + }).join(''); + + // Add event listeners + list.querySelectorAll('[data-layer-row]').forEach(row => { + row.addEventListener('click', () => { + const idx = parseInt(row.dataset.layerRow, 10); + setCurrentLayer(idx); + renderLayersList(); + }); + }); + 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); + // Delay re-render slightly so a double-click can cancel it + if (nameClickTimer) clearTimeout(nameClickTimer); + nameClickTimer = setTimeout(() => { + renderLayersList(); + }, 220); + e.stopPropagation(); + }); + // Enable editing on double-click (or double tap) + inp.addEventListener('dblclick', (e) => { + if (nameClickTimer) { clearTimeout(nameClickTimer); nameClickTimer = null; } + e.stopPropagation(); + inp.readOnly = false; + inp.style.cursor = 'text'; + inp.focus(); + try { inp.select(); } catch {} + }); + // Commit change on blur or change; then return to readOnly + const commit = () => { + const idx = parseInt(inp.dataset.layerName, 10); + const newName = inp.value.trim(); + if (newName) { + state.layers[idx].name = newName; + } else { + inp.value = state.layers[idx].name; // Revert if empty + } + inp.readOnly = true; + inp.style.cursor = 'pointer'; + }; + inp.addEventListener('change', commit); + inp.addEventListener('blur', commit); + // Allow Enter to confirm, Escape to cancel + inp.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + inp.blur(); + } else if (e.key === 'Escape') { + inp.value = state.layers[parseInt(inp.dataset.layerName, 10)].name; + inp.blur(); + } + }); + }); + list.querySelectorAll('[data-layer-toggle-vis]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.layerToggleVis, 10); + toggleLayerVisibility(idx); + renderLayersList(); + }); + }); + list.querySelectorAll('[data-layer-toggle-lock]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.layerToggleLock, 10); + toggleLayerLock(idx); + renderLayersList(); + }); + }); + list.querySelectorAll('[data-layer-delete]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.layerDelete, 10); + if (deleteLayer(idx)) renderLayersList(); + }); + }); + } + + // Initial render + renderLayersList(); + + // Add layer button + const addLayerBtn = menu.querySelector('#ctx_add_layer'); + if (addLayerBtn) { + addLayerBtn.addEventListener('click', () => { + addLayer(); + renderLayersList(); + }); + } + + // Clamp initial position and anchor to button + const clamp = (px, py) => { + const nx = Math.max(0, Math.min(window.innerWidth - menu.offsetWidth, px)); + const ny = Math.max(0, Math.min(window.innerHeight - menu.offsetHeight, py)); + return { nx, ny }; + }; + let px = x, py = y; + if (anchorEl) { + const r = anchorEl.getBoundingClientRect(); + ui.contextAnchor = { anchor: anchorEl, dx: x - r.left, dy: y - r.top }; + } else { + ui.contextAnchor = null; + } + const cl = clamp(px, py); + menu.style.left = cl.nx + 'px'; + menu.style.top = cl.ny + 'px'; + ui.contextMenu = menu; + + // Wire context controls + const tgl = menu.querySelector('#ctx_toggle'); + const clr = menu.querySelector('#ctx_clear'); + const col = menu.querySelector('#ctx_color'); + const w = menu.querySelector('#ctx_width'); + const wv = menu.querySelector('#ctx_wv'); + const op = menu.querySelector('#ctx_opacity'); + const ov = menu.querySelector('#ctx_ov'); + const ers = menu.querySelector('#ctx_eraser'); + const cst = menu.querySelector('#ctx_constant'); + const dsh = menu.querySelector('#ctx_dashed'); + const hdp = menu.querySelector('#ctx_hidpi'); + const hid = menu.querySelector('#ctx_hidden'); + ui.hiddenChk = hid; // Store reference for keyboard shortcut + const swf = menu.querySelector('#ctx_savewf'); + const exp = menu.querySelector('#ctx_export'); + const imp = menu.querySelector('#ctx_import'); + const cls = menu.querySelector('#ctx_close'); + + tgl.addEventListener('click', () => { + setEnabled(!state.enabled); + // Update menu button color immediately + tgl.style.background = state.enabled ? '#4CAF50' : '#f44336'; + tgl.textContent = state.enabled ? 'ENABLED' : 'DISABLED'; + }); + clr.addEventListener('click', () => { + // Clear all non-locked layers + for (const lyr of state.layers) if (!lyr.locked) lyr.paths = []; + // Refresh flat paths for backward compatibility + 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(); + }); + 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); + }); + 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)); + }); + ers.addEventListener('click', () => { + setEraserMode(!state.eraser); + // Update context button state immediately + ers.textContent = state.eraser ? '🩹 Eraser' : '✏️ Draw'; + ers.style.background = state.eraser ? '#c2185b' : '#795548'; + }); + cst.addEventListener('change', () => { state.constantScreen = !!cst.checked; app?.canvas?.setDirty(true,true); }); + dsh.addEventListener('change', () => { state.dashed = !!dsh.checked; 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); }); + swf.addEventListener('change', () => { state.saveWithWorkflow = !!swf.checked; if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra(); }); + exp.addEventListener('click', () => exportAnnotations()); + imp.addEventListener('click', () => { + 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(); }); + const offClick = (ev) => { if (!menu.contains(ev.target) && ev.target !== ui.floating) { menu.remove(); ui.contextAnchor = null; window.removeEventListener('mousedown', offClick, true); } }; + window.addEventListener('mousedown', offClick, true); + } + + function toGraphPos(e, canvas) { + if (!canvas) return null; + if (typeof canvas.convertEventToCanvasOffset === 'function') { + const p = canvas.convertEventToCanvasOffset(e); + return { x: p[0], y: p[1] }; + } + // 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); + // 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] }; + } + + function attachCanvasHooks() { + const canvas = app?.canvas; + if (!canvas || !canvas.canvas) { + console.log('[IAMCCS] Canvas not ready'); + return false; + } + + console.log('[IAMCCS] Attaching canvas hooks...'); + + // Draw annotations into an offscreen buffer, then composite on top (so eraser doesn't reveal black background) + const prev = canvas.onDrawForeground; + canvas.onDrawForeground = function (ctx) { + if (typeof prev === 'function') prev.call(this, ctx); + // Create floating button on first draw (proper timing) + if (!state.uiShown) { + console.log('[IAMCCS] Creating floating button...'); + ensureFloatingToggle(); + state.uiShown = true; + } + if (state.hidden) return; // do not render when hidden + + const cw = ctx.canvas.width | 0; + const ch = ctx.canvas.height | 0; + const supersample = state.hiDPIx2 ? 2 : 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) { + __annoW = __annoCanvas.width = targetW; + __annoH = __annoCanvas.height = targetH; + } else { + __annoCtx.clearRect(0, 0, __annoW, __annoH); + } + + const ds = canvas.ds || canvas.viewport || { scale: canvas.scale || 1, offset: canvas.offset || [0, 0] }; + const dpr = (window.devicePixelRatio || 1) * supersample; + 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, + }); + const widthPx = (w) => state.constantScreen ? (w * dpr) : (w * (ds.scale || 1) * 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))); + const b = Math.max(2, Math.min(200, Math.round(2 * wpx))); + return [a, b]; + }; + + __annoCtx.save(); + __annoCtx.imageSmoothingEnabled = true; + __annoCtx.setTransform(1,0,0,1,0,0); + __annoCtx.lineCap = 'round'; + __annoCtx.lineJoin = 'round'; + // Draw stored strokes + for (const layer of state.layers) { + if (!layer.visible) continue; + for (const path of layer.paths) { + if (!Array.isArray(path.points) || !path.points.length) continue; + const wpx = widthPx(path.width); + __annoCtx.globalAlpha = typeof path.opacity === 'number' ? path.opacity : 1; + if (path.dashed) __annoCtx.setLineDash(dashForWidth(wpx)); else __annoCtx.setLineDash([]); + if (path.mode === 'erase') { + __annoCtx.save(); + __annoCtx.globalCompositeOperation = 'destination-out'; + const pts = path.points.map(g2s); + if (pts.length === 1) { + // Erase a dot + const r = Math.max(0.5, wpx / 2); + __annoCtx.beginPath(); + __annoCtx.arc(pts[0].x, pts[0].y, r, 0, Math.PI * 2); + __annoCtx.fillStyle = 'rgba(0,0,0,1)'; + __annoCtx.fill(); + } else if (pts.length === 2) { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + __annoCtx.lineTo(pts[1].x, pts[1].y); + __annoCtx.lineWidth = Math.max(1, wpx); + __annoCtx.strokeStyle = 'rgba(0,0,0,1)'; + __annoCtx.stroke(); + } else { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length - 1; i++) { + const cx = pts[i].x; + const cy = pts[i].y; + const nx = (pts[i].x + pts[i+1].x) * 0.5; + const ny = (pts[i].y + pts[i+1].y) * 0.5; + __annoCtx.quadraticCurveTo(cx, cy, nx, ny); + } + __annoCtx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + __annoCtx.lineWidth = Math.max(1, wpx); + __annoCtx.strokeStyle = 'rgba(0,0,0,1)'; + __annoCtx.stroke(); + } + __annoCtx.restore(); + } else { + __annoCtx.globalCompositeOperation = 'source-over'; + __annoCtx.strokeStyle = path.color || '#ff4444'; + __annoCtx.lineWidth = Math.max(1, wpx); + const pts = path.points.map(g2s); + if (pts.length === 1) { + __annoCtx.beginPath(); + __annoCtx.arc(pts[0].x, pts[0].y, Math.max(0.5, wpx/2), 0, Math.PI*2); + __annoCtx.fillStyle = path.color || '#ff4444'; + __annoCtx.fill(); + } else if (pts.length === 2) { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + __annoCtx.lineTo(pts[1].x, pts[1].y); + __annoCtx.stroke(); + } else { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length - 1; i++) { + const cx = pts[i].x; + const cy = pts[i].y; + const nx = (pts[i].x + pts[i+1].x) * 0.5; + const ny = (pts[i].y + pts[i+1].y) * 0.5; + __annoCtx.quadraticCurveTo(cx, cy, nx, ny); + } + // Ensure we reach the last point + __annoCtx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + __annoCtx.stroke(); + } + } + } + } + // Draw current stroke + const cur = state.current; + if (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([]); + if (cur.mode === 'erase') { + __annoCtx.save(); + __annoCtx.globalCompositeOperation = 'destination-out'; + const pts = cur.points.map(g2s); + if (pts.length === 1) { + const r = Math.max(0.5, wpx / 2); + __annoCtx.beginPath(); + __annoCtx.arc(pts[0].x, pts[0].y, r, 0, Math.PI * 2); + __annoCtx.fillStyle = 'rgba(0,0,0,1)'; + __annoCtx.fill(); + } else if (pts.length === 2) { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + __annoCtx.lineTo(pts[1].x, pts[1].y); + __annoCtx.lineWidth = Math.max(1, wpx); + __annoCtx.strokeStyle = 'rgba(0,0,0,1)'; + __annoCtx.stroke(); + } else { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length - 1; i++) { + const cx = pts[i].x; + const cy = pts[i].y; + const nx = (pts[i].x + pts[i+1].x) * 0.5; + const ny = (pts[i].y + pts[i+1].y) * 0.5; + __annoCtx.quadraticCurveTo(cx, cy, nx, ny); + } + __annoCtx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + __annoCtx.lineWidth = Math.max(1, wpx); + __annoCtx.strokeStyle = 'rgba(0,0,0,1)'; + __annoCtx.stroke(); + } + __annoCtx.restore(); + } else { + __annoCtx.globalCompositeOperation = 'source-over'; + __annoCtx.strokeStyle = cur.color || '#ff4444'; + __annoCtx.lineWidth = Math.max(1, wpx); + const pts = cur.points.map(g2s); + if (pts.length === 1) { + __annoCtx.beginPath(); + __annoCtx.arc(pts[0].x, pts[0].y, Math.max(0.5, wpx/2), 0, Math.PI*2); + __annoCtx.fillStyle = cur.color || '#ff4444'; + __annoCtx.fill(); + } else if (pts.length === 2) { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + __annoCtx.lineTo(pts[1].x, pts[1].y); + __annoCtx.stroke(); + } else { + __annoCtx.beginPath(); + __annoCtx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length - 1; i++) { + const cx = pts[i].x; + const cy = pts[i].y; + const nx = (pts[i].x + pts[i+1].x) * 0.5; + const ny = (pts[i].y + pts[i+1].y) * 0.5; + __annoCtx.quadraticCurveTo(cx, cy, nx, ny); + } + __annoCtx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + __annoCtx.stroke(); + } + } + } + __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(); + }; + + // 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 + const p = toGraphPos(e, canvas); + if (!p) 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.current = { color: state.color, width: state.width, opacity: state.opacity, dashed: state.dashed, mode: state.eraser ? 'erase' : 'draw', points: [p] }; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, true); + }; + const onMove = (e) => { + if (!state.current) return; + const p = toGraphPos(e, canvas); + if (!p) return; + state.current.points.push(p); + // Change cursor to pen while drawing + el.style.cursor = state.eraser ? 'cell' : 'crosshair'; + e.preventDefault(); + e.stopPropagation(); + app.canvas.setDirty(true, false); + }; + const onUp = (e) => { + // Release pointer capture if active + try { if (e && typeof el.releasePointerCapture === 'function') el.releasePointerCapture(e.pointerId); } catch {} + if (!state.current) return; + const layer = getCurrentLayer(); + if (layer && !layer.locked) { + layer.paths.push(state.current); + // Also add to state.paths for backward compatibility + state.paths.push(state.current); + } + state.current = null; + // Reset cursor after drawing + el.style.cursor = 'default'; + app.canvas.setDirty(true, true); + persistToGraphExtra(); + }; + + el.addEventListener('pointerdown', onDown, true); + el.addEventListener('pointermove', onMove, true); + // Prefer element-level pointerup with pointer capture; add window fallback + el.addEventListener('pointerup', onUp, true); + el.addEventListener('pointercancel', onUp, true); + window.addEventListener('pointerup', onUp, false); + + console.log('[IAMCCS] Hooks attached'); + return true; + } + + // Register as ComfyUI extension + app.registerExtension({ + name: 'IAMCCS.Annotate', + init() { + // 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) => { + // Avoid intercepting when typing into inputs/editable fields + const target = e.target; + const isEditing = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || target.closest?.('[contenteditable="true"]')); + if (isEditing) return; + if (e.altKey && (e.key === 'a' || e.key === 'A')) { + setEnabled(!state.enabled); + 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'); + 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; + app?.canvas?.setDirty(true, true); + 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(); + promptImportWorkflow(); + } else if (e.key === 'Escape') { + if (state.current) { + state.paths.push(state.current); + state.current = null; + app?.canvas?.setDirty(true, true); + } + } + }); + }, + setup() { + const wait = () => { + if (app?.canvas?.canvas) { + console.log('[IAMCCS] Setup called - canvas ready'); + attachCanvasHooks(); + // Fallback: create button after 1 second if not created by draw + setTimeout(() => { + if (!state.uiShown) { + console.log('[IAMCCS] Fallback: creating floating button...'); + ensureFloatingToggle(); + state.uiShown = true; + } + }, 1000); + // Attempt to load from workflow.extra after initial render + setTimeout(() => handleWorkflowChange(), 300); + // Safely wrap app.loadGraphData to update annotations when workflows change + try { + if (!app.__iamccs_wrapped_load) { + const orig = app.loadGraphData?.bind(app); + if (typeof orig === 'function') { + app.loadGraphData = async function(...args) { + const res = await orig(...args); + try { handleWorkflowChange(); } catch (e) { console.warn('[IAMCCS] handleWorkflowChange failed:', e); } + return res; + }; + Object.defineProperty(app, '__iamccs_wrapped_load', { value: true, configurable: true }); + } + } + } catch (e) { + console.warn('[IAMCCS] Could not wrap app.loadGraphData:', e); + } + return; + } + setTimeout(wait, 250); + }; + wait(); + }, + }); + + console.log('[IAMCCS] Extension registered'); + })(); +} + +// Handle loading/clearing annotations when workflow changes +function handleWorkflowChange() { + try { + const g = window?.app?.graph || (window?.ComfyApp?.app?.graph); + } catch {} + try { + // app is module local above; retrieve via global import cache if available + } catch {} + try { + // The async IIFE scope holds state and helpers; expose a small bridge on window + const mod = window.IAMCCS_ANNOTATE_MODULE; + if (mod && typeof mod._handleWorkflowChange === 'function') mod._handleWorkflowChange(); + } catch {} +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93520a8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,41 @@ +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "IAMCCS-Annotate" +version = "1.0.0" +description = "Drawing and annotation tool for ComfyUI with layer support" +readme = "README.md" +license = {text = "MIT"} +authors = [ + {name = "IAMCCS", email = "info@carminecristalloscalzi.com"} +] +keywords = ["comfyui", "drawing", "annotation", "extension", "web-ui"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + "Topic :: Artistic Software", + "Topic :: Multimedia :: Graphics", +] +requires-python = ">=3.8" +dependencies = [] + +[project.urls] +Homepage = "https://github.com/IAMCCS" +Repository = "https://github.com/IAMCCS/IAMCCS_annotate" +Issues = "https://github.com/IAMCCS/IAMCCS_annotate/issues" + +[project.optional-dependencies] +dev = ["pytest", "black", "flake8"] + +[tool.setuptools] +packages = ["IAMCCS_annotate"] +include-package-data = true + +[tool.setuptools.package-data] +IAMCCS_annotate = ["js/*.js", "*.md", "*.py"] diff --git a/version.json b/version.json new file mode 100644 index 0000000..01fe551 --- /dev/null +++ b/version.json @@ -0,0 +1,6 @@ +{ + "name": "iamccs-annotate", + "version": "1.0.0", + "author": "Carmine Cristallo Scalzi (IAMCCS)", + "description": "drawing and annotation tool for ComfyUI." +}