7714 lines
356 KiB
JavaScript
7714 lines
356 KiB
JavaScript
// 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;
|
||
// Written code by Carmine Cristallo Scalzi (IAMCCS) - AI for debugging - section: Initialization Guard - reason: documents single-run protection preventing duplicate extension setup
|
||
|
||
(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: 7,
|
||
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,
|
||
dashed: false,
|
||
hidden: false,
|
||
penOnly: false,
|
||
activePointerId: null,
|
||
hiDPIx2: false,
|
||
// Per-mode memory for brush and opacity
|
||
widthDraw: 7,
|
||
widthErase: 48,
|
||
opacityDraw: 1.0,
|
||
opacityErase: 1.0,
|
||
// 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 = {
|
||
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,
|
||
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);
|
||
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 {}
|
||
}
|
||
|
||
// 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 <input type="color"> 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;
|
||
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';
|
||
// 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) {
|
||
const tgl = ui.contextMenu.querySelector('#ctx_toggle');
|
||
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');
|
||
if (dashed) dashed.checked = !!state.dashed;
|
||
const constant = document.getElementById('iam_constant');
|
||
if (constant) constant.checked = !!state.constantScreen;
|
||
const hidpi = document.getElementById('iam_hidpi');
|
||
if (hidpi) hidpi.checked = !!state.hiDPIx2;
|
||
const hidden = document.getElementById('iam_hidden');
|
||
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 {
|
||
if (ui.contextMenu) {
|
||
const dsh = ui.contextMenu.querySelector('#ctx_dashed');
|
||
if (dsh) dsh.checked = !!state.dashed;
|
||
const cst = ui.contextMenu.querySelector('#ctx_constant');
|
||
if (cst) cst.checked = !!state.constantScreen;
|
||
const hdp = ui.contextMenu.querySelector('#ctx_hidpi');
|
||
if (hdp) hdp.checked = !!state.hiDPIx2;
|
||
const hid = ui.contextMenu.querySelector('#ctx_hidden');
|
||
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);
|
||
state.current = null;
|
||
}
|
||
// 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
|
||
// Sync UI to reflect current state
|
||
if (ui.eraserBtn) {
|
||
ui.eraserBtn.textContent = state.eraser ? '🩹 Eraser' : '✏️ Draw';
|
||
ui.eraserBtn.style.background = state.eraser ? '#c2185b' : '#795548';
|
||
}
|
||
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');
|
||
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 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;
|
||
const sty = getCurrentLayerStyle();
|
||
if (next) {
|
||
// switching to eraser: remember current draw values, apply eraser values
|
||
sty.widthDraw = state.width;
|
||
sty.opacityDraw = state.opacity;
|
||
state.widthDraw = sty.widthDraw;
|
||
state.opacityDraw = sty.opacityDraw;
|
||
state.width = sty.widthErase || state.width;
|
||
state.opacity = (typeof sty.opacityErase === 'number') ? sty.opacityErase : state.opacity;
|
||
} else {
|
||
// switching to draw: remember current eraser values, apply draw values
|
||
sty.widthErase = state.width;
|
||
sty.opacityErase = state.opacity;
|
||
state.widthErase = sty.widthErase;
|
||
state.opacityErase = sty.opacityErase;
|
||
state.width = sty.widthDraw || state.width;
|
||
state.opacity = (typeof sty.opacityDraw === 'number') ? sty.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);
|
||
try { persistToGraphExtra(); } catch {}
|
||
}
|
||
|
||
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 = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||
<div style="font-weight:700;color:#fff;">🎨 Annotate</div>
|
||
<div style="font-size:11px;color:#bbb;">graph-locked</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||
<button id="iam_toggle" style="flex:1;padding:8px;border:none;border-radius:6px;background:#f44336;color:#fff;font-weight:700;cursor:pointer;">DISABLED</button>
|
||
<button id="iam_clear" title="Clear all" style="padding:8px 10px;border:none;border-radius:6px;background:#666;color:#fff;cursor:pointer;">🗑️</button>
|
||
</div>
|
||
<div style="display:flex;gap:10px;align-items:center;">
|
||
<input id="iam_color" type="color" value="${state.color}" style="width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;">
|
||
<div style="flex:1;">
|
||
<div style="font-size:11px;color:#bbb;">Brush: <span id="iam_wv">15</span>px</div>
|
||
<input id="iam_width" type="range" min="1" max="48" value="15" style="width:100%;">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;align-items:center;margin-top:8px;">
|
||
<div style="font-size:11px;color:#bbb;">Opacity: <span id="iam_ov">100</span>%</div>
|
||
<input id="iam_opacity" type="range" min="10" max="100" step="5" value="100" style="flex:1;">
|
||
</div>
|
||
|
||
<div style="margin-top:10px;border-top:1px solid #555;padding-top:8px;">
|
||
<div style="font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;">Tools</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||
<button id="iam_tool_draw" style="padding:6px 8px;border:none;border-radius:6px;background:#4CAF50;color:#fff;cursor:pointer;font-size:12px;">✏️ Draw</button>
|
||
<button id="iam_tool_select" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">🔲 Select</button>
|
||
<button id="iam_tool_transform" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">⛶ Transform</button>
|
||
<button id="iam_tool_rotate" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">⟲ Rotate</button>
|
||
<button id="iam_tool_shot" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">📸 Shot</button>
|
||
<select id="iam_select_mode" style="padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;">
|
||
<option value="rect">Rect</option>
|
||
<option value="lasso">Lasso</option>
|
||
</select>
|
||
<select id="iam_transform_mode" style="padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;">
|
||
<option value="fixed">Fixed</option>
|
||
<option value="freeform">Freeform</option>
|
||
</select>
|
||
<button id="iam_sel_clear" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Clear Sel</button>
|
||
<button id="iam_sel_copy" title="Ctrl+C" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Copy</button>
|
||
<button id="iam_sel_cut" title="Ctrl+X" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Cut</button>
|
||
<button id="iam_sel_paste" title="Ctrl+V" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Paste</button>
|
||
<button id="iam_undo" title="Undo" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Undo</button>
|
||
<button id="iam_redo" title="Redo" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Redo</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:10px;border-top:1px solid #555;padding-top:8px;">
|
||
<div style="font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;">Screenshot Post-it</div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||
<input id="iam_sticker_color" type="color" value="${state.stickerFrameColor || '#ffffff'}" style="width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;" title="Frame color">
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Pad
|
||
<input id="iam_sticker_pad" type="range" min="0" max="40" value="${Number(state.stickerPaddingPx) || 0}" style="width:120px;">
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Border
|
||
<input id="iam_sticker_border" type="range" min="0" max="10" value="${Number(state.stickerBorderWidthPx) || 0}" style="width:90px;">
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_sticker_shadow" type="checkbox" ${state.stickerShadow ? 'checked' : ''}>
|
||
<span>Shadow</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Strength
|
||
<input id="iam_sticker_shadow_strength" type="range" min="0" max="30" value="${Number(state.stickerShadowStrength) || 0}" style="width:110px;">
|
||
</label>
|
||
<button id="iam_sticker_clear" title="Remove all post-its" style="padding:6px 8px;border:none;border-radius:6px;background:#6d4c41;color:#fff;cursor:pointer;font-size:12px;">Clear Post-its</button>
|
||
<button id="iam_sticker_purge" title="Remove old screenshot cache blobs (IndexedDB)" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">Purge old</button>
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
|
||
<button id="iam_eraser" style="padding:6px 10px;border:none;border-radius:6px;background:#795548;color:#fff;cursor:pointer;">✏️ Draw</button>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_constant" type="checkbox">
|
||
<span>Constant width</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_dashed" type="checkbox">
|
||
<span>Dashed</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_hidpi" type="checkbox">
|
||
<span>HiDPI ×2</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_hidden" type="checkbox">
|
||
<span>Hide notes</span>
|
||
<span data-iam-hidden-badge style="display:${state.hidden ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);">HIDE</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_penonly" type="checkbox">
|
||
<span>Pen only</span>
|
||
<span data-iam-penonly-badge style="display:${state.penOnly ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);">PEN</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_pinmode" type="checkbox">
|
||
<span>Pin/unpin</span>
|
||
<span data-iam-pin-badge style="display:${state.pinMode ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);">PIN</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_sel_override" type="checkbox">
|
||
<span>sel</span>
|
||
<span data-iam-sel-badge style="display:${state.selOverride ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);">SEL</span>
|
||
</label>
|
||
<div style="flex:1"></div>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-top:8px;">
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="iam_save_with_wf" type="checkbox" ${state.saveWithWorkflow ? 'checked' : ''}>
|
||
<span>Save into workflow</span>
|
||
</label>
|
||
<div style="flex:1"></div>
|
||
<button id="iam_save_now" title="Save now into workflow extra" style="padding:6px 8px;border:none;border-radius:6px;background:#607d8b;color:#fff;cursor:pointer;font-size:11px;">Save WF</button>
|
||
<button id="iam_load_now" title="Load from workflow extra" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:11px;">Load WF</button>
|
||
</div>
|
||
<div style="margin-top:8px;font-size:10px;color:#9aa;opacity:0.85;text-align:right;line-height:1.2;">IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0<br>www.carminecristalloscalzi.com</div>
|
||
`;
|
||
|
||
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 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
|
||
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)');
|
||
// 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;
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
dashedChk.addEventListener('change', () => {
|
||
state.dashed = !!dashedChk.checked;
|
||
try { saveStateToLayerStyle(undefined, { persist: true }); } catch {}
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
hidpiChk.addEventListener('change', () => {
|
||
state.hiDPIx2 = !!hidpiChk.checked;
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
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;
|
||
if (state.saveWithWorkflow) persistToGraphExtra(); else removeFromGraphExtra();
|
||
});
|
||
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
|
||
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();
|
||
syncToolsUI();
|
||
syncStickerUI();
|
||
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 = `
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
|
||
<div style="font-weight:700;color:#fff;">🎨 Annotate</div>
|
||
<div style="font-size:11px;color:#bbb;">graph-locked</div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||
<button id=\"iam_toggle\" style=\"flex:1;padding:8px;border:none;border-radius:6px;background:#f44336;color:#fff;font-weight:700;cursor:pointer;\">DISABLED</button>
|
||
<button id=\"iam_clear\" title=\"Clear all\" style=\"padding:8px 10px;border:none;border-radius:6px;background:#666;color:#fff;cursor:pointer;\">🗑️</button>
|
||
</div>
|
||
<div style=\"display:flex;gap:10px;align-items:center;\">
|
||
<input id=\"iam_color\" type=\"color\" value=\"${state.color}\" style=\"width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;\">
|
||
<div style=\"flex:1;\">
|
||
<div style=\"font-size:11px;color:#bbb;\">Brush: <span id=\"iam_wv\">15</span>px</div>
|
||
<input id=\"iam_width\" type=\"range\" min=\"1\" max=\"48\" value=\"15\" style=\"width:100%;\">
|
||
</div>
|
||
</div>
|
||
<div style=\"display:flex;gap:10px;align-items:center;margin-top:8px;\">
|
||
<div style=\"font-size:11px;color:#bbb;\">Opacity: <span id=\"iam_ov\">100</span>%</div>
|
||
<input id=\"iam_opacity\" type=\"range\" min=\"10\" max=\"100\" step=\"5\" value=\"100\" style=\"flex:1;\">
|
||
</div>
|
||
|
||
<div style=\"margin-top:10px;border-top:1px solid #555;padding-top:8px;\">
|
||
<div style=\"font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;\">Tools</div>
|
||
<div style=\"display:flex;gap:6px;flex-wrap:wrap;align-items:center;\">
|
||
<button id=\"iam_tool_draw\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#4CAF50;color:#fff;cursor:pointer;font-size:12px;\">✏️ Draw</button>
|
||
<button id=\"iam_tool_select\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;\">🔲 Select</button>
|
||
<button id=\"iam_tool_transform\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;\">⛶ Transform</button>
|
||
<button id=\"iam_tool_shot\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;\">📸 Shot</button>
|
||
<select id=\"iam_select_mode\" style=\"padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;\">
|
||
<option value=\"rect\">Rect</option>
|
||
<option value=\"lasso\">Lasso</option>
|
||
</select>
|
||
<select id=\"iam_transform_mode\" style=\"padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;\">
|
||
<option value=\"fixed\">Fixed</option>
|
||
<option value=\"freeform\">Freeform</option>
|
||
</select>
|
||
<button id=\"iam_sel_clear\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Clear Sel</button>
|
||
<button id=\"iam_sel_copy\" title=\"Ctrl+C\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Copy</button>
|
||
<button id=\"iam_sel_cut\" title=\"Ctrl+X\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Cut</button>
|
||
<button id=\"iam_sel_paste\" title=\"Ctrl+V\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Paste</button>
|
||
<button id=\"iam_undo\" title=\"Undo\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Undo</button>
|
||
<button id=\"iam_redo\" title=\"Redo\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;\">Redo</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style=\"margin-top:10px;border-top:1px solid #555;padding-top:8px;\">
|
||
<div style=\"font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;\">Screenshot Post-it</div>
|
||
<div style=\"display:flex;gap:8px;align-items:center;flex-wrap:wrap;\">
|
||
<input id=\"iam_sticker_color\" type=\"color\" value=\"${state.stickerFrameColor || '#ffffff'}\" style=\"width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;\" title=\"Frame color\">
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">Pad
|
||
<input id=\"iam_sticker_pad\" type=\"range\" min=\"0\" max=\"40\" value=\"${Number(state.stickerPaddingPx) || 0}\" style=\"width:120px;\">
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">Border
|
||
<input id=\"iam_sticker_border\" type=\"range\" min=\"0\" max=\"10\" value=\"${Number(state.stickerBorderWidthPx) || 0}\" style=\"width:90px;\">
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_sticker_shadow\" type=\"checkbox\" ${state.stickerShadow ? 'checked' : ''}>
|
||
<span>Shadow</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">Strength
|
||
<input id=\"iam_sticker_shadow_strength\" type=\"range\" min=\"0\" max=\"30\" value=\"${Number(state.stickerShadowStrength) || 0}\" style=\"width:110px;\">
|
||
</label>
|
||
<button id=\"iam_sticker_clear\" title=\"Remove all post-its\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#6d4c41;color:#fff;cursor:pointer;font-size:12px;\">Clear Post-its</button>
|
||
<button id=\"iam_sticker_purge\" title=\"Remove old screenshot cache blobs (IndexedDB)\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;\">Purge old</button>
|
||
</div>
|
||
</div>
|
||
<div style=\"display:flex;gap:8px;align-items:center;margin-top:8px;flex-wrap:wrap;\">
|
||
<button id=\"iam_eraser\" style=\"padding:6px 10px;border:none;border-radius:6px;background:#795548;color:#fff;cursor:pointer;\">✏️ Draw</button>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_constant\" type=\"checkbox\">
|
||
<span>Constant width</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_dashed\" type=\"checkbox\">
|
||
<span>Dashed</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_hidpi\" type=\"checkbox\">
|
||
<span>HiDPI ×2</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_hidden\" type=\"checkbox\">
|
||
<span>Hide notes</span>
|
||
<span data-iam-hidden-badge style=\"display:${state.hidden ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);\">HIDE</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_penonly\" type=\"checkbox\">
|
||
<span>Pen only</span>
|
||
<span data-iam-penonly-badge style=\"display:${state.penOnly ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);\">PEN</span>
|
||
</label>
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_sel_override\" type=\"checkbox\">
|
||
<span>sel</span>
|
||
<span data-iam-sel-badge style=\"display:${state.selOverride ? 'inline-flex' : 'none'};align-items:center;justify-content:center;height:16px;padding:0 6px;border-radius:999px;background:#ff9800;color:#111;font-weight:800;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);\">SEL</span>
|
||
</label>
|
||
<div style=\"flex:1\"></div>
|
||
</div>
|
||
<div style=\"display:flex;gap:8px;align-items:center;margin-top:8px;\">
|
||
<label style=\"display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;\">
|
||
<input id=\"iam_save_with_wf\" type=\"checkbox\" ${state.saveWithWorkflow ? 'checked' : ''}>
|
||
<span>Save into workflow</span>
|
||
</label>
|
||
<div style=\"flex:1\"></div>
|
||
<button id=\"iam_save_now\" title=\"Save now into workflow extra\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#607d8b;color:#fff;cursor:pointer;font-size:11px;\">Save WF</button>
|
||
<button id=\"iam_load_now\" title=\"Load from workflow extra\" style=\"padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:11px;\">Load WF</button>
|
||
</div>
|
||
<div style=\"margin-top:8px;font-size:10px;color:#9aa;opacity:0.85;text-align:right;line-height:1.2;\">IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0<br>www.carminecristalloscalzi.com</div>
|
||
`;
|
||
// 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 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);
|
||
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 = [];
|
||
// 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)');
|
||
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 {} }
|
||
});
|
||
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;
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
dashedChk.addEventListener('change', () => {
|
||
state.dashed = !!dashedChk.checked;
|
||
try { saveStateToLayerStyle(undefined, { persist: true }); } catch {}
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
hidpiChk.addEventListener('change', () => {
|
||
state.hiDPIx2 = !!hidpiChk.checked;
|
||
app?.canvas?.setDirty(true, true);
|
||
});
|
||
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);
|
||
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 {}
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
syncToolsUI();
|
||
syncStickerUI();
|
||
// Hide floating toggle if dock is visible
|
||
if (ui.floating) ui.floating.style.display = 'none';
|
||
return panel;
|
||
}
|
||
|
||
function ensureStateHydratedFromExisting() {
|
||
try {
|
||
if (state.hydrated) return;
|
||
const existing = readAnnotationsFromExtra(getActiveGraphKey());
|
||
if (!existing) return;
|
||
// Build merged layers from existing
|
||
let mergedLayers = [];
|
||
if (Array.isArray(existing.layers) && existing.layers.length) {
|
||
mergedLayers = existing.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 })) : [],
|
||
})) : [],
|
||
style: (layer.style && typeof layer.style === 'object') ? {
|
||
color: typeof layer.style.color === 'string' ? layer.style.color : (state.color || '#ff4444'),
|
||
dashed: !!layer.style.dashed,
|
||
widthDraw: (typeof layer.style.widthDraw === 'number') ? layer.style.widthDraw : (state.widthDraw || 7),
|
||
widthErase: (typeof layer.style.widthErase === 'number') ? layer.style.widthErase : (state.widthErase || 48),
|
||
opacityDraw: (typeof layer.style.opacityDraw === 'number') ? layer.style.opacityDraw : (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0),
|
||
opacityErase: (typeof layer.style.opacityErase === 'number') ? layer.style.opacityErase : (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0),
|
||
} : {
|
||
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),
|
||
},
|
||
}));
|
||
} else if (Array.isArray(existing.paths)) {
|
||
mergedLayers = [{ name: 'Layer 1', visible: true, locked: false, paths: existing.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 })) : [],
|
||
})), 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) } }];
|
||
}
|
||
// Overlay current, appending new paths and style tweaks
|
||
for (let i = 0; i < state.layers.length; i++) {
|
||
const cur = state.layers[i];
|
||
ensureLayerStyle(cur);
|
||
if (!mergedLayers[i]) {
|
||
mergedLayers[i] = { name: cur.name || `Layer ${i+1}`, visible: cur.visible !== false, locked: !!cur.locked, paths: [], style: { ...cur.style } };
|
||
}
|
||
if (Array.isArray(cur.paths) && cur.paths.length) mergedLayers[i].paths.push(...cur.paths);
|
||
// Update style with current layer's style as most recent
|
||
mergedLayers[i].style = { ...mergedLayers[i].style, ...cur.style };
|
||
}
|
||
state.layers = mergedLayers;
|
||
// 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;
|
||
} catch (e) {
|
||
console.warn('[IAMCCS] ensureStateHydratedFromExisting failed:', e);
|
||
}
|
||
}
|
||
|
||
function persistToGraphExtra(force = false) {
|
||
if (!app?.graph) return;
|
||
// Merge existing annotations if we haven't hydrated yet (prevents overwriting)
|
||
ensureStateHydratedFromExisting();
|
||
app.graph.extra = app.graph.extra || {};
|
||
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 applyAnnotationsData(data) {
|
||
if (!data) return false;
|
||
|
||
// 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 => ({
|
||
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 })) : [],
|
||
})) : [],
|
||
style: (layer.style && typeof layer.style === 'object') ? {
|
||
color: typeof layer.style.color === 'string' ? layer.style.color : (state.color || '#ff4444'),
|
||
dashed: !!layer.style.dashed,
|
||
widthDraw: (typeof layer.style.widthDraw === 'number') ? layer.style.widthDraw : (state.widthDraw || 7),
|
||
widthErase: (typeof layer.style.widthErase === 'number') ? layer.style.widthErase : (state.widthErase || 48),
|
||
opacityDraw: (typeof layer.style.opacityDraw === 'number') ? layer.style.opacityDraw : (typeof state.opacityDraw === 'number' ? state.opacityDraw : 1.0),
|
||
opacityErase: (typeof layer.style.opacityErase === 'number') ? layer.style.opacityErase : (typeof state.opacityErase === 'number' ? state.opacityErase : 1.0),
|
||
} : { 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.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]) {
|
||
ensureLayerStyle(state.layers[0]);
|
||
state.layers[0].paths = [...state.paths];
|
||
} else {
|
||
state.layers = [{ name: 'Layer 1', visible: true, locked: false, paths: [...state.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) } }];
|
||
}
|
||
} 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');
|
||
// 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)
|
||
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);
|
||
// Put into first layer for compatibility
|
||
if (!state.layers[0]) state.layers[0] = { 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) } };
|
||
ensureLayerStyle(state.layers[0]);
|
||
state.layers[0].paths = [...state.paths];
|
||
state.hydrated = 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);
|
||
state.hydrated = 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() {
|
||
// 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) {
|
||
// 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: '#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 {}
|
||
|
||
// Layer management + per-layer style
|
||
function ensureLayerStyle(layer) {
|
||
if (!layer) return;
|
||
layer.style = layer.style || {};
|
||
if (typeof layer.style.color !== 'string') layer.style.color = state.color || '#ff4444';
|
||
if (typeof layer.style.dashed !== 'boolean') layer.style.dashed = !!state.dashed;
|
||
if (typeof layer.style.widthDraw !== 'number') layer.style.widthDraw = state.widthDraw || 7;
|
||
if (typeof layer.style.widthErase !== 'number') layer.style.widthErase = state.widthErase || 48;
|
||
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);
|
||
return lyr;
|
||
}
|
||
function getCurrentLayerStyle() {
|
||
const lyr = getCurrentLayer();
|
||
return lyr?.style || {};
|
||
}
|
||
function applyLayerStyleToState() {
|
||
const style = getCurrentLayerStyle();
|
||
state.color = style.color;
|
||
state.dashed = !!style.dashed;
|
||
if (state.eraser) {
|
||
state.width = style.widthErase;
|
||
state.opacity = style.opacityErase;
|
||
} else {
|
||
state.width = style.widthDraw;
|
||
state.opacity = style.opacityDraw;
|
||
}
|
||
state.widthDraw = style.widthDraw;
|
||
state.widthErase = style.widthErase;
|
||
state.opacityDraw = style.opacityDraw;
|
||
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: {
|
||
color: typeof base.color === 'string' ? base.color : (state.color || '#ff4444'),
|
||
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),
|
||
} });
|
||
state.currentLayerIdx = state.layers.length - 1;
|
||
app?.canvas?.setDirty(true, true);
|
||
persistToGraphExtra(true);
|
||
return state.layers.length - 1;
|
||
}
|
||
function deleteLayer(idx) {
|
||
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();
|
||
}
|
||
}
|
||
|
||
// Keep menu anchored to the floating button when it moves/resizes
|
||
function repositionContextMenuIfAnchored() {
|
||
try {
|
||
if (!ui.contextMenu || !ui.contextAnchor) return;
|
||
let anchor = ui.contextAnchor.anchor;
|
||
if (!anchor || !document.body.contains(anchor)) {
|
||
// If anchor was recreated, prefer current floating button
|
||
if (ui.floating && document.body.contains(ui.floating)) {
|
||
// Keep relative offset to the new button using previous dx/dy if possible
|
||
const r = ui.floating.getBoundingClientRect();
|
||
const nx = Math.max(0, Math.min(window.innerWidth - ui.contextMenu.offsetWidth, r.left + (ui.contextAnchor.dx || 0)));
|
||
const ny = Math.max(0, Math.min(window.innerHeight - ui.contextMenu.offsetHeight, r.top + (ui.contextAnchor.dy || -ui.contextMenu.offsetHeight - 8)));
|
||
ui.contextMenu.style.left = nx + 'px';
|
||
ui.contextMenu.style.top = ny + 'px';
|
||
ui.contextAnchor = { anchor: ui.floating, dx: (ui.contextAnchor.dx||0), dy: (ui.contextAnchor.dy||-ui.contextMenu.offsetHeight - 8) };
|
||
return;
|
||
}
|
||
return;
|
||
}
|
||
const r = anchor.getBoundingClientRect();
|
||
const nx = Math.max(0, Math.min(window.innerWidth - ui.contextMenu.offsetWidth, r.left + (ui.contextAnchor.dx||0)));
|
||
const ny = Math.max(0, Math.min(window.innerHeight - ui.contextMenu.offsetHeight, r.top + (ui.contextAnchor.dy||0)));
|
||
ui.contextMenu.style.left = nx + 'px';
|
||
ui.contextMenu.style.top = ny + 'px';
|
||
} 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');
|
||
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);
|
||
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)
|
||
btn.addEventListener('contextmenu', (e) => {
|
||
e.preventDefault();
|
||
// If any menu is open, close it and stop propagation so global right-click closer doesn't interfere
|
||
if (ui.contextMenu) {
|
||
e.stopPropagation();
|
||
closeContextMenu('toggle-btn');
|
||
return;
|
||
}
|
||
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, {
|
||
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';
|
||
}
|
||
}
|
||
});
|
||
// Keep anchored on window resize/scroll
|
||
window.addEventListener('resize', repositionContextMenuIfAnchored, true);
|
||
window.addEventListener('scroll', repositionContextMenuIfAnchored, true);
|
||
return btn;
|
||
}
|
||
|
||
let __ctxOffClick = null;
|
||
let __ctxOffRight = null;
|
||
function closeContextMenu(reason) {
|
||
try {
|
||
if (ui.contextMenu && document.body.contains(ui.contextMenu)) ui.contextMenu.remove();
|
||
ui.contextMenu = null;
|
||
ui.contextAnchor = null;
|
||
if (__ctxOffClick) {
|
||
window.removeEventListener('mousedown', __ctxOffClick, true);
|
||
__ctxOffClick = null;
|
||
}
|
||
if (__ctxOffRight) {
|
||
window.removeEventListener('contextmenu', __ctxOffRight, true);
|
||
__ctxOffRight = null;
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function showContextMenu(x, y, anchorEl) {
|
||
// Remove existing
|
||
if (ui.contextMenu && document.body.contains(ui.contextMenu)) closeContextMenu('reopen');
|
||
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 = `
|
||
<div style="font-weight:700;margin-bottom:8px;">IAMCCS_annotate - Options</div>
|
||
<div style="display:flex;gap:6px;margin-bottom:8px;">
|
||
<button id="ctx_toggle" style="flex:1;padding:6px 8px;border:none;border-radius:6px;background:${state.enabled ? '#4CAF50' : '#f44336'};color:#fff;cursor:pointer;">${state.enabled ? 'ENABLED' : 'DISABLED'}</button>
|
||
<button id="ctx_clear" title="Clear all" style="padding:6px 8px;border:none;border-radius:6px;background:#666;color:#fff;cursor:pointer;">🗑️</button>
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;">
|
||
<input id="ctx_color" type="color" value="${state.color}" style="width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;">
|
||
<div style="flex:1;">
|
||
<div style="font-size:11px;color:#bbb;">Brush: <span id="ctx_wv">${state.width}</span>px</div>
|
||
<input id="ctx_width" type="range" min="1" max="48" value="${state.width}" style="width:100%;">
|
||
</div>
|
||
</div>
|
||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:8px;">
|
||
<div style="font-size:11px;color:#bbb;">Opacity: <span id="ctx_ov">${Math.round(state.opacity*100)}</span>%</div>
|
||
<input id="ctx_opacity" type="range" min="10" max="100" step="5" value="${Math.round(state.opacity*100)}" style="flex:1;">
|
||
</div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap;">
|
||
<button id="ctx_eraser" style="padding:6px 8px;border:none;border-radius:6px;background:${state.eraser ? '#c2185b' : '#795548'};color:#fff;cursor:pointer;">${state.eraser ? '🩹 Eraser' : '✏️ Draw'}</button>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_constant" type="checkbox" ${state.constantScreen ? 'checked' : ''}>
|
||
<span>Constant width</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_dashed" type="checkbox" ${state.dashed ? 'checked' : ''}>
|
||
<span>Dashed</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_hidpi" type="checkbox" ${state.hiDPIx2 ? 'checked' : ''}>
|
||
<span>HiDPI ×2</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_hidden" type="checkbox" ${state.hidden ? 'checked' : ''}>
|
||
<span>Hide notes</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_penonly" type="checkbox" ${state.penOnly ? 'checked' : ''}>
|
||
<span>Pen only</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_pinmode" type="checkbox" ${state.pinMode ? 'checked' : ''}>
|
||
<span>Pin/unpin</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_sel_override" type="checkbox" ${state.selOverride ? 'checked' : ''}>
|
||
<span>sel</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_savewf" type="checkbox" ${state.saveWithWorkflow ? 'checked' : ''}>
|
||
<span>Save into WF</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div style="margin-top:10px;border-top:1px solid #555;padding-top:8px;">
|
||
<div style="font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;">Tools</div>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||
<button id="ctx_tool_draw" style="padding:6px 8px;border:none;border-radius:6px;background:#4CAF50;color:#fff;cursor:pointer;font-size:12px;">✏️ Draw</button>
|
||
<button id="ctx_tool_select" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">🔲 Select</button>
|
||
<button id="ctx_tool_transform" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">⛶ Transform</button>
|
||
<button id="ctx_tool_rotate" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">⟲ Rotate</button>
|
||
<button id="ctx_tool_shot" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">📸 Shot</button>
|
||
<select id="ctx_select_mode" style="padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;">
|
||
<option value="rect">Rect</option>
|
||
<option value="lasso">Lasso</option>
|
||
</select>
|
||
<select id="ctx_transform_mode" style="padding:6px 8px;border-radius:6px;background:#263238;color:#fff;border:1px solid #444;font-size:12px;">
|
||
<option value="fixed">Fixed</option>
|
||
<option value="freeform">Freeform</option>
|
||
</select>
|
||
<button id="ctx_sel_clear" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Clear Sel</button>
|
||
<button id="ctx_sel_copy" title="Ctrl+C" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Copy</button>
|
||
<button id="ctx_sel_cut" title="Ctrl+X" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Cut</button>
|
||
<button id="ctx_sel_paste" title="Ctrl+V" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Paste</button>
|
||
<button id="ctx_undo" title="Undo" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Undo</button>
|
||
<button id="ctx_redo" title="Redo" style="padding:6px 8px;border:none;border-radius:6px;background:#37474f;color:#fff;cursor:pointer;font-size:12px;">Redo</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top:10px;border-top:1px solid #555;padding-top:8px;">
|
||
<div style="font-weight:600;color:#fff;font-size:12px;margin-bottom:6px;">Screenshot Post-it</div>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||
<input id="ctx_sticker_color" type="color" value="${state.stickerFrameColor || '#ffffff'}" style="width:36px;height:28px;border:none;border-radius:4px;background:transparent;cursor:pointer;" title="Frame color">
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Pad
|
||
<input id="ctx_sticker_pad" type="range" min="0" max="40" value="${Number(state.stickerPaddingPx) || 0}" style="width:120px;">
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Border
|
||
<input id="ctx_sticker_border" type="range" min="0" max="10" value="${Number(state.stickerBorderWidthPx) || 0}" style="width:90px;">
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">
|
||
<input id="ctx_sticker_shadow" type="checkbox" ${state.stickerShadow ? 'checked' : ''}>
|
||
<span>Shadow</span>
|
||
</label>
|
||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:#ddd;">Strength
|
||
<input id="ctx_sticker_shadow_strength" type="range" min="0" max="30" value="${Number(state.stickerShadowStrength) || 0}" style="width:110px;">
|
||
</label>
|
||
<button id="ctx_sticker_clear" title="Remove all post-its" style="padding:6px 8px;border:none;border-radius:6px;background:#6d4c41;color:#fff;cursor:pointer;font-size:12px;">Clear Post-its</button>
|
||
<button id="ctx_sticker_purge" title="Remove old screenshot cache blobs (IndexedDB)" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;font-size:12px;">Purge old</button>
|
||
</div>
|
||
</div>
|
||
<div style="margin-top:10px;border-top:1px solid #555;padding-top:8px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||
<div style="font-weight:600;color:#fff;font-size:12px;">Layers</div>
|
||
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;justify-content:flex-end;">
|
||
<span id="ctx_text_controls" style="display:${state._textControlsOpen ? 'flex' : 'none'};gap:6px;align-items:center;">
|
||
<select id="ctx_text_font" style="padding:2px 6px;border-radius:4px;background:#263238;color:#fff;border:1px solid #444;font-size:11px;max-width:160px;"></select>
|
||
<input id="ctx_text_size" type="number" min="8" max="200" step="1" value="${Number(state.textFontSize) || 28}" style="width:66px;padding:2px 6px;border-radius:4px;background:#263238;color:#fff;border:1px solid #444;font-size:11px;" title="Font size">
|
||
<button id="ctx_text_norm" title="Normal" style="padding:2px 6px;border:none;border-radius:4px;background:#37474f;color:#fff;cursor:pointer;font-size:11px;">N</button>
|
||
<button id="ctx_text_bold" title="Bold" style="padding:2px 6px;border:none;border-radius:4px;background:${state.textFontWeight === 'bold' ? '#fb8c00' : '#37474f'};color:#fff;cursor:pointer;font-size:11px;">B</button>
|
||
<button id="ctx_text_italic" title="Italic" style="padding:2px 6px;border:none;border-radius:4px;background:${state.textFontStyle === 'italic' ? '#fb8c00' : '#37474f'};color:#fff;cursor:pointer;font-size:11px;">I</button>
|
||
<button id="ctx_text_underline" title="Underline" style="padding:2px 6px;border:none;border-radius:4px;background:${state.textUnderline ? '#fb8c00' : '#37474f'};color:#fff;cursor:pointer;font-size:11px;">U</button>
|
||
</span>
|
||
<button id="ctx_add_text" title="Create a Text layer (drag to size, then type)" style="padding:2px 6px;border:none;border-radius:4px;background:#039be5;color:#fff;cursor:pointer;font-size:11px;">+ Text</button>
|
||
<button id="ctx_add_layer" title="Add new layer" style="padding:2px 6px;border:none;border-radius:4px;background:#4CAF50;color:#fff;cursor:pointer;font-size:11px;">+ Layer</button>
|
||
</div>
|
||
</div>
|
||
<div id="ctx_layers_list" style="max-height:150px;overflow-y:auto;"></div>
|
||
</div>
|
||
<div style="display:flex;gap:6px;justify-content:space-between;align-items:center;margin-top:8px;">
|
||
<div style="font-size:10px;color:#9aa;opacity:0.85;line-height:1.2;">IAMCCS_annotate - draw & note on ComfyUI . v-2.0.0<br>www.carminecristalloscalzi.com</div>
|
||
<div>
|
||
<button id="ctx_export" title="Export workflow + annotations" style="padding:6px 8px;border:none;border-radius:6px;background:#00695c;color:#fff;cursor:pointer;">Export Workflow+Notes</button>
|
||
<button id="ctx_import" title="Import annotations from JSON" style="padding:6px 8px;border:none;border-radius:6px;background:#455a64;color:#fff;cursor:pointer;">Import JSON</button>
|
||
<button id="ctx_close" style="padding:6px 10px;border:none;border-radius:6px;background:#263238;color:#fff;cursor:pointer;">Close</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
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();
|
||
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 `
|
||
<div data-layer-row="${idx}" style="display:flex;gap:4px;align-items:center;padding:4px 6px;margin:2px 0;background:${idx === state.currentLayerIdx ? 'rgba(76,175,80,0.2)' : 'rgba(255,255,255,0.05)'};border-radius:4px;border:1px solid ${idx === state.currentLayerIdx ? '#4CAF50' : 'transparent'};">
|
||
<input data-layer-name="${idx}" type="text" value="${layer.name}" readonly style="flex:1;padding:2px 4px;border:none;border-radius:3px;background:rgba(0,0,0,0.3);color:#fff;font-size:11px;cursor:pointer;" title="Double-click to rename; click to select">
|
||
<button data-layer-toggle-vis="${idx}" title="Toggle visibility" style="padding:2px 4px;border:none;border-radius:3px;background:#555;color:#fff;cursor:pointer;font-size:10px;">${layer.visible ? '👁️' : '🚫'}</button>
|
||
<button data-layer-toggle-lock="${idx}" title="Toggle lock" style="padding:2px 4px;border:none;border-radius:3px;background:${layer.locked ? '#d32f2f' : '#2e7d32'};color:#fff;cursor:pointer;font-size:10px;">${layer.locked ? '🔒' : '🔓'}</button>
|
||
${layer.kind === 'text' ? `<button data-layer-edit-text="${idx}" title="Edit text" style="padding:2px 4px;border:none;border-radius:3px;background:#0277bd;color:#fff;cursor:pointer;font-size:10px;">✎</button>` : ''}
|
||
<button data-layer-delete="${idx}" ${canDelete ? '' : 'disabled'} title="${canDelete ? 'Delete layer' : 'At least one normal layer must remain'}" style="padding:2px 4px;border:none;border-radius:3px;background:${canDelete ? '#d32f2f' : '#555'};color:#fff;cursor:${canDelete ? 'pointer' : 'not-allowed'};font-size:10px;opacity:${canDelete ? '1' : '0.6'};">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Add event listeners
|
||
list.querySelectorAll('[data-layer-row]').forEach(row => {
|
||
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(() => {
|
||
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);
|
||
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();
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-layer-toggle-lock]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
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();
|
||
});
|
||
});
|
||
list.querySelectorAll('[data-layer-delete]').forEach(btn => {
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const idx = parseInt(btn.dataset.layerDelete, 10);
|
||
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();
|
||
|
||
// Add layer button
|
||
const addLayerBtn = menu.querySelector('#ctx_add_layer');
|
||
if (addLayerBtn) {
|
||
addLayerBtn.addEventListener('click', () => {
|
||
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 => `<option value="${f}">${f}</option>`).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));
|
||
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;
|
||
|
||
// 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');
|
||
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');
|
||
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');
|
||
|
||
tgl.addEventListener('click', () => {
|
||
setEnabled(!state.enabled);
|
||
// 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
|
||
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);
|
||
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 {} }
|
||
});
|
||
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;
|
||
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; 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', () => {
|
||
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');
|
||
}
|
||
};
|
||
window.addEventListener('mousedown', __ctxOffClick, true);
|
||
// Close also on any right-click outside the menu
|
||
__ctxOffRight = (ev) => {
|
||
// Ignore right-click on the floating toggle; button handler will manage toggle/close
|
||
if (!menu.contains(ev.target) && ev.target !== ui.floating) closeContextMenu('right-click');
|
||
};
|
||
window.addEventListener('contextmenu', __ctxOffRight, 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 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] };
|
||
}
|
||
|
||
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);
|
||
// 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 (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;
|
||
}
|
||
|
||
// 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;
|
||
|
||
// 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) {
|
||
__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 = 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 + off0) * sc) * dpr,
|
||
y: ((pt.y + off1) * sc) * 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)));
|
||
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;
|
||
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);
|
||
__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 (!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([]);
|
||
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();
|
||
|
||
// 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 (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;
|
||
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;
|
||
|
||
// 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) => {
|
||
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';
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
app.canvas.setDirty(true, false);
|
||
};
|
||
const onUp = (e) => {
|
||
// Release pointer capture if active
|
||
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();
|
||
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;
|
||
state.activePointerId = null;
|
||
// Reset cursor after drawing
|
||
el.style.cursor = 'default';
|
||
app.canvas.setDirty(true, true);
|
||
persistToGraphExtra(true);
|
||
};
|
||
|
||
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);
|
||
el.addEventListener('lostpointercapture', onUp, true);
|
||
window.addEventListener('pointerup', onUp, false);
|
||
|
||
console.log('[IAMCCS] Hooks attached');
|
||
return true;
|
||
}
|
||
|
||
// Register as ComfyUI extension
|
||
// Written code by Carmine Cristallo Scalzi (IAMCCS) - AI for debugging - section: Extension Registration - reason: marks lifecycle hook integration (init/setup) with ComfyUI for event binding & canvas hooks
|
||
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) => {
|
||
// 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 (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();
|
||
promptImportWorkflow();
|
||
} else if (e.key === 'Escape') {
|
||
if (state.current) {
|
||
state.paths.push(state.current);
|
||
state.current = null;
|
||
app?.canvas?.setDirty(true, true);
|
||
} else if (state.selection) {
|
||
clearSelection();
|
||
}
|
||
}
|
||
});
|
||
},
|
||
setup() {
|
||
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 || !ui.floating || !document.body.contains(ui.floating)) {
|
||
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) {
|
||
// 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;
|
||
};
|
||
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();
|
||
},
|
||
});
|
||
|
||
// 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');
|
||
})();
|
||
}
|
||
|
||
// 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 {}
|
||
}
|
||
|
||
// This code was written entirely by hand by Carmine Cristallo Scalzi (IAMCCS) with final AI-assisted debugging – if you copy parts of the code, the result of hard work, please mention the author! Thank you and happy experimenting!
|