The context menu gains a "Move to root" item that unparents the right-clicked node (or the whole selection if that row is part of it) — a discoverable alternative to dragging onto the header. Disabled when nothing in the target is nested. The node's subtree comes with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
3306 lines
196 KiB
JavaScript
3306 lines
196 KiB
JavaScript
/*
|
||
* Part of ComfyUI-AI2Go-Utils.
|
||
*
|
||
* Derived from ComfyUI-KJNodes (web/js/ideogram4_prompt_builder.js, web/js/utility.js)
|
||
* by Kijai, licensed under GPL-3.0; this file and pack remain GPL-3.0.
|
||
* Upstream: https://github.com/kijai/ComfyUI-KJNodes
|
||
*/
|
||
import { chainCallback, addMiddleClickPan, addWheelPassthrough, cursorForBboxMode, watchImageInputs, captureVideoFrame, makeUUID } from './utility.js';
|
||
const { app } = window.comfyAPI.app;
|
||
|
||
const HANDLE = 8; // hit radius (canvas px) for corners/edges
|
||
const MAX_ELEM_COLORS = 5; // Ideogram 4 per-element palette cap
|
||
const MAX_STYLE_COLORS = 16; // Ideogram 4 style palette cap
|
||
const DOCK_MINW = 300, DOCK_MINH = 240; // dock min size — kept in sync with the .ai2go-ideo-dock CSS below
|
||
let copiedBoxes = null; // internal clipboard for copy/paste of regions (array; shared across nodes)
|
||
|
||
// Track the most recent generated image so it can be grabbed as a background.
|
||
let lastResultImage = null;
|
||
try {
|
||
// Keep the latest image of the prompt; by the time the prompt finishes this is the real output
|
||
// (the output node runs after the sampler), so an early image-preview node never wins.
|
||
app.api?.addEventListener?.("executed", (e) => {
|
||
const imgs = e?.detail?.output?.images;
|
||
if (Array.isArray(imgs) && imgs.length) lastResultImage = imgs[imgs.length - 1];
|
||
});
|
||
// Grab the full-res final once the prompt completes — independent of sampler previews, so it works
|
||
// even when sampling preview is disabled.
|
||
app.api?.addEventListener?.("execution_success", () => {
|
||
for (const n of livePreviewNodes) n._ideoGrabFinal?.();
|
||
});
|
||
} catch (e) {}
|
||
function resultViewUrl(img) {
|
||
const p = new URLSearchParams({ filename: img.filename || "", subfolder: img.subfolder || "", type: img.type || "output" });
|
||
return "/view?" + p.toString();
|
||
}
|
||
|
||
// Nodes opted into "live background": feed them the sampling preview frames as they arrive.
|
||
const livePreviewNodes = new Set();
|
||
let _lastPreviewBlob = null;
|
||
let _previewOffset = 0; // byte offset of the image inside the preview blob (0 = clean image)
|
||
function pushPreview(img) { for (const n of livePreviewNodes) n._ideoSetLiveBg?.(img); }
|
||
// Decode a blob to an <img> via object URL (same path the final result uses); resolves null on failure.
|
||
function decodeToImg(blob) {
|
||
return new Promise((resolve) => {
|
||
const url = URL.createObjectURL(blob);
|
||
const img = new Image();
|
||
img.onload = () => { resolve(img); URL.revokeObjectURL(url); }; // img retains its data after revoke
|
||
img.onerror = () => { resolve(null); URL.revokeObjectURL(url); };
|
||
img.src = url;
|
||
});
|
||
}
|
||
// Offset of an embedded JPEG (FF D8 FF) or PNG signature, or -1. Some servers frame the preview
|
||
// payload with header bytes the bundled frontend doesn't strip, so the image isn't at byte 0.
|
||
function imageStart(u) {
|
||
for (let i = 0; i + 3 < u.length; i++) {
|
||
if (u[i] === 0xff && u[i + 1] === 0xd8 && u[i + 2] === 0xff) return i;
|
||
if (u[i] === 0x89 && u[i + 1] === 0x50 && u[i + 2] === 0x4e && u[i + 3] === 0x47) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
async function feedPreviewBlob(blob) {
|
||
// Both "b_preview" and "b_preview_with_metadata" can fire for the same frame (same Blob) — dedupe by identity.
|
||
if (!blob || blob === _lastPreviewBlob || !livePreviewNodes.size) return;
|
||
_lastPreviewBlob = blob;
|
||
let img = await decodeToImg(_previewOffset ? blob.slice(_previewOffset) : blob);
|
||
if (!img) { // wrong/stale offset — locate the embedded image and retry
|
||
const off = imageStart(new Uint8Array(await blob.arrayBuffer()));
|
||
_previewOffset = off > 0 ? off : 0;
|
||
if (off >= 0) img = await decodeToImg(off > 0 ? blob.slice(off) : blob);
|
||
}
|
||
if (img) pushPreview(img);
|
||
}
|
||
try {
|
||
app.api?.addEventListener?.("b_preview", (e) => feedPreviewBlob(e?.detail));
|
||
app.api?.addEventListener?.("b_preview_with_metadata", (e) => feedPreviewBlob(e?.detail?.blob));
|
||
} catch (e) {}
|
||
|
||
// Named caption-JSON templates, each stored as its own file server-side via ComfyUI's userdata
|
||
// API (under the ComfyUI user dir) so they persist across browsers/machines and survive clears.
|
||
const TPL_DIR = "ai2go/ideogram4/templates";
|
||
const tplSafe = (s) => (s || "").replace(/[\/\\:*?"<>|]+/g, "_").trim(); // filesystem-safe name
|
||
const tplFile = (name) => `${TPL_DIR}/${name}.json`;
|
||
async function listTemplateNames() {
|
||
try {
|
||
const items = await app.api.listUserDataFullInfo(TPL_DIR);
|
||
return items.map((it) => it.path.split(/[\\/]/).pop() || "")
|
||
.filter((f) => /\.json$/i.test(f))
|
||
.map((f) => f.replace(/\.json$/i, ""))
|
||
.filter(Boolean).sort((a, b) => a.localeCompare(b));
|
||
} catch (e) { return []; }
|
||
}
|
||
async function loadTemplate(name) {
|
||
try { const r = await app.api.getUserData(tplFile(name)); if (r.status === 200) return await r.text(); } catch (e) {}
|
||
return null;
|
||
}
|
||
async function saveTemplate(name, caption) {
|
||
try {
|
||
await app.api.storeUserData(tplFile(name), caption, { overwrite: true, stringify: false, throwOnError: true });
|
||
return true;
|
||
} catch (e) { window.alert("Couldn't save the template to the server."); return false; }
|
||
}
|
||
async function deleteTemplate(name) {
|
||
try { await app.api.deleteUserData(tplFile(name)); } catch (e) {}
|
||
}
|
||
|
||
// Parse a #rrggbb hex into {r,g,b}, or null if malformed.
|
||
function hexRgb(hex) {
|
||
const h = (hex || "").replace("#", "");
|
||
if (h.length < 6) return null;
|
||
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) };
|
||
}
|
||
// Perceived luminance (0-255) of an {r,g,b}.
|
||
function luminance({ r, g, b }) { return 0.299 * r + 0.587 * g + 0.114 * b; }
|
||
|
||
// The hex color lightened toward white if too dark, so it stays readable on the dark canvas.
|
||
function readableText(hex) {
|
||
const c = hexRgb(hex);
|
||
if (!c) return "#d4d4d4";
|
||
let { r, g, b } = c;
|
||
const lum = luminance(c), MIN = 130;
|
||
if (lum < MIN) {
|
||
const t = (MIN - lum) / (255 - lum);
|
||
r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t);
|
||
}
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
// Black or white, whichever contrasts better with the given hex background.
|
||
function textOn(hex) {
|
||
const c = hexRgb(hex);
|
||
if (!c) return "#000";
|
||
return luminance(c) > 140 ? "#000" : "#fff";
|
||
}
|
||
|
||
// Parse a clipboard string into a normalized #rrggbb, or null if it isn't a color.
|
||
function parseColorString(s) {
|
||
if (!s) return null;
|
||
s = s.trim();
|
||
let m = s.match(/^#?([0-9a-fA-F]{6})$/);
|
||
if (m) return "#" + m[1].toLowerCase();
|
||
m = s.match(/^#?([0-9a-fA-F]{3})$/);
|
||
if (m) { const h = m[1]; return ("#" + h[0] + h[0] + h[1] + h[1] + h[2] + h[2]).toLowerCase(); }
|
||
m = s.match(/^rgba?\(\s*(\d+)\s*[, ]\s*(\d+)\s*[, ]\s*(\d+)/i);
|
||
if (m) {
|
||
const h2 = (n) => Math.max(0, Math.min(255, parseInt(n, 10))).toString(16).padStart(2, "0");
|
||
return "#" + h2(m[1]) + h2(m[2]) + h2(m[3]);
|
||
}
|
||
return null;
|
||
}
|
||
// The palette swatch under the cursor + a setter, so Ctrl+V pastes a color onto it
|
||
// and Ctrl+C copies its hex.
|
||
let hoveredSwatch = null;
|
||
document.addEventListener("keydown", (e) => {
|
||
if (!hoveredSwatch) return;
|
||
const ctrl = e.ctrlKey || e.metaKey;
|
||
if (!ctrl) return;
|
||
const key = e.key.toLowerCase();
|
||
if (key !== "v" && key !== "c") return;
|
||
const ae = document.activeElement; // don't steal copy/paste from a focused text field
|
||
if (ae && (ae.tagName === "TEXTAREA" || ae.tagName === "INPUT" || ae.isContentEditable)) return;
|
||
const target = hoveredSwatch;
|
||
if (!target.sw || !target.sw.isConnected) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (key === "c") {
|
||
navigator.clipboard?.writeText?.(target.sw.dataset.hex || "").catch(() => {});
|
||
} else {
|
||
navigator.clipboard?.readText?.().then((txt) => {
|
||
const c = parseColorString(txt);
|
||
if (c && target.sw && target.sw.isConnected) target.setColor(c);
|
||
}).catch(() => {});
|
||
}
|
||
}, true);
|
||
|
||
// The node whose canvas is under the cursor — lets H toggle box visibility on hover (no click needed).
|
||
let hoveredCanvasNode = null;
|
||
document.addEventListener("keydown", (e) => {
|
||
if (!hoveredCanvasNode) return;
|
||
if (e.ctrlKey || e.metaKey || e.altKey || (e.key !== "h" && e.key !== "H")) return;
|
||
const ae = document.activeElement;
|
||
if (ae && (ae.tagName === "TEXTAREA" || ae.tagName === "INPUT" || ae.isContentEditable)) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
hoveredCanvasNode._toggleHideBoxes?.();
|
||
}, true);
|
||
// Alt held over a canvas: preview the alt-click cycle target even without moving the mouse.
|
||
document.addEventListener("keydown", (e) => { if (hoveredCanvasNode && e.key === "Alt") hoveredCanvasNode._altRefresh?.(true); }, true);
|
||
document.addEventListener("keyup", (e) => { if (hoveredCanvasNode && e.key === "Alt") hoveredCanvasNode._altRefresh?.(false); }, true);
|
||
|
||
// Pinned dock follows the node: Nodes 2.0 hosts it inside the node element (inherits its transform); legacy is body-fixed.
|
||
const pinnedDocks = new Set();
|
||
const liveDocks = new Set(); // every node with a dock — swept so an orphaned dock can't linger
|
||
window.addEventListener("resize", () => wakeDocks()); // canvas may shift without pan/zoom
|
||
// Safety net: a dock whose node has left the graph (deleted, workflow cleared) is torn down even if onRemoved didn't fire.
|
||
function sweepOrphanDocks() {
|
||
for (const n of liveDocks) {
|
||
if (n.graph?.getNodeById?.(n.id) === n) continue; // still registered in its graph
|
||
try { n._dockRO?.disconnect(); } catch (e) {}
|
||
try { n._visObserver?.disconnect(); } catch (e) {}
|
||
n._dockEl?.remove();
|
||
pinnedDocks.delete(n); liveDocks.delete(n);
|
||
}
|
||
}
|
||
// What the dock follows in the displayed graph, or null to hide: the node ("self", offset=dockGraph),
|
||
// or — opt-in via exposeToParent — the SubgraphNode wrapping its subgraph ("parent", offset=dockParent; v1 direct parent only).
|
||
function dockAnchor(n) {
|
||
const c = app.canvas;
|
||
if (!c || !c.graph) return null;
|
||
if (n.graph === c.graph) return { host: n, ctx: "self" };
|
||
if (n.properties.exposeToParent && n.graph) {
|
||
const nodes = c.graph._nodes || c.graph.nodes || [];
|
||
const sn = nodes.find((m) => m && m.subgraph === n.graph); // SubgraphNode wrapping the node's subgraph
|
||
if (sn) return { host: sn, ctx: "parent" };
|
||
}
|
||
return null;
|
||
}
|
||
// gr.x/gr.y = offset (graph units) from host.pos (host = the node, or its SubgraphNode at parent level);
|
||
// size (w/h) always lives in dockGraph. `rect` lets the tick loop share one canvas getBoundingClientRect.
|
||
function applyDockTransform(n, anchor, rect) {
|
||
const c = app.canvas, fl = n._dockEl;
|
||
if (!c || !fl) return;
|
||
anchor = anchor || dockAnchor(n);
|
||
if (!anchor) return; // off-screen; visibility handled in tickDocks
|
||
const host = anchor.host;
|
||
n._dockHost = host; n._dockCtx = anchor.ctx; // drag/snap handlers read these
|
||
const gr = anchor.ctx === "parent"
|
||
? (n.properties.dockParent || (n.properties.dockParent = { x: 0, y: (host.size ? host.size[1] : 0) + 2 }))
|
||
: n.properties.dockGraph;
|
||
if (!gr) return;
|
||
let hostEl = null;
|
||
if (window.LiteGraph?.vueNodesMode && host.id != null) { // cache; re-query if the host changed or it's gone
|
||
if (n._dockHostId !== host.id || !n._dockHostEl || !n._dockHostEl.isConnected) {
|
||
n._dockHostEl = document.querySelector(`[data-node-id="${host.id}"]`);
|
||
n._dockHostId = host.id; n._dockSig = "";
|
||
}
|
||
hostEl = n._dockHostEl;
|
||
}
|
||
if (hostEl) { // host inside the host element → inherits its transform
|
||
const title = window.LiteGraph?.NODE_TITLE_HEIGHT ?? 30; // element top is the title bar; host.pos sits below it
|
||
const sig = "v|" + host.id + "|" + gr.x + "|" + gr.y;
|
||
if (fl.parentElement !== hostEl) {
|
||
hostEl.appendChild(fl);
|
||
fl.style.position = "absolute"; fl.style.transform = ""; fl.style.transformOrigin = ""; fl.style.zIndex = "";
|
||
n._dockSig = "";
|
||
}
|
||
if (n._dockSig !== sig) { fl.style.left = gr.x + "px"; fl.style.top = (title + gr.y) + "px"; n._dockSig = sig; }
|
||
return;
|
||
}
|
||
// Legacy / fallback (no Vue element, e.g. just after a mode switch): body-fixed from host.pos + transform.
|
||
if (fl.parentElement !== document.body) { document.body.appendChild(fl); fl.style.left = ""; fl.style.top = ""; n._dockSig = ""; } // re-attach if orphaned
|
||
if (!host.pos) return;
|
||
const ds = c.ds, scale = ds.scale;
|
||
rect = rect || c.canvas.getBoundingClientRect();
|
||
const baseLeft = rect.left + (host.pos[0] + gr.x + ds.offset[0]) * scale;
|
||
const baseTop = rect.top + (host.pos[1] + gr.y + ds.offset[1]) * scale;
|
||
const tf = `translate(${baseLeft}px,${baseTop}px) scale(${scale})`;
|
||
if (n._dockSig !== tf) { fl.style.position = "fixed"; fl.style.transformOrigin = "top left"; fl.style.transform = tf; n._dockSig = tf; }
|
||
const order = c.graph?.nodes?.indexOf(host); // sit at the node DOM-widget layer (not above everything)
|
||
if (order != null && order >= 0) fl.style.zIndex = String(order);
|
||
}
|
||
// Dock chrome follows the node: color theme (title → header, body → background; dark fallback when uncolored)
|
||
// and pinned state (node pinned → dock locked, no drag/resize).
|
||
function applyDockTheme(n) {
|
||
const fl = n._dockEl; if (!fl) return;
|
||
const pinned = !!(n.flags && n.flags.pinned);
|
||
const sig = (n.color || "") + "|" + (n.bgcolor || "") + "|" + pinned;
|
||
if (n._dockTheme === sig) return;
|
||
n._dockTheme = sig;
|
||
const set = (k, v) => (v ? fl.style.setProperty(k, v) : fl.style.removeProperty(k));
|
||
set("--ai2go-dock-bg", n.bgcolor);
|
||
set("--ai2go-dock-head", n.color);
|
||
set("--ai2go-dock-border", n.color || n.bgcolor);
|
||
fl.classList.toggle("pinned", pinned);
|
||
}
|
||
// Event-driven loop: woken by canvas redraws (dirty-gated) + resize, self-stops when settled.
|
||
const DOCK_IDLE_STOP = 6; // stop after this many frames with no further wake
|
||
let _dockRAF = 0, _dockLastMode = null, _dockIdle = 0, _dockWakesInstalled = false;
|
||
function tickDocks() {
|
||
const c = app.canvas;
|
||
if (c && pinnedDocks.size) {
|
||
const vue = !!window.LiteGraph?.vueNodesMode;
|
||
if (vue !== _dockLastMode) { // legacy↔2.0 flip rebuilds the node DOM — force re-parent + re-place
|
||
_dockLastMode = vue;
|
||
for (const n of pinnedDocks) { n._dockSig = ""; n._dockHostEl = null; n._dockHostId = null; }
|
||
}
|
||
const rect = vue ? null : c.canvas.getBoundingClientRect(); // legacy: share one rect across all docks
|
||
for (const n of pinnedDocks) { // anchor = node (own graph on screen) or its SubgraphNode (exposed)
|
||
applyDockTheme(n);
|
||
if (n._exBtn) n._exBtn.style.display = (n.graph && !n.graph.isRootGraph) ? "" : "none"; // expose only matters inside a subgraph
|
||
const anchor = dockAnchor(n);
|
||
if (n._dockEl) n._dockEl.style.display = anchor ? "" : "none";
|
||
if (anchor) applyDockTransform(n, anchor, rect);
|
||
}
|
||
}
|
||
if (pinnedDocks.size && ++_dockIdle < DOCK_IDLE_STOP) _dockRAF = requestAnimationFrame(tickDocks);
|
||
else _dockRAF = 0; // settled — stop until the next wake
|
||
}
|
||
function wakeDocks() { // a canvas change happened — run/continue the loop until it settles
|
||
_dockIdle = 0;
|
||
if (!_dockRAF && pinnedDocks.size) _dockRAF = requestAnimationFrame(tickDocks);
|
||
}
|
||
function installDockWakes() { // onDrawForeground only fires when dirty_canvas is set
|
||
if (_dockWakesInstalled) return;
|
||
const c = app.canvas; if (!c) return;
|
||
_dockWakesInstalled = true;
|
||
chainCallback(c, "onDrawForeground", () => { sweepOrphanDocks(); wakeDocks(); });
|
||
}
|
||
function startDockLoop() { installDockWakes(); wakeDocks(); }
|
||
|
||
// Pointer-capture drag: filters moves by pointerId, auto-removes its listeners on release, calls onEnd.
|
||
function dragPointer(e, target, onMove, onEnd) {
|
||
try { target.setPointerCapture(e.pointerId); } catch (er) {}
|
||
const move = (me) => { if (me.pointerId === e.pointerId) onMove(me); };
|
||
const end = (ue) => {
|
||
if (ue.pointerId !== e.pointerId) return;
|
||
target.removeEventListener("pointermove", move);
|
||
target.removeEventListener("pointerup", end);
|
||
target.removeEventListener("pointercancel", end);
|
||
if (onEnd) onEnd(ue);
|
||
};
|
||
target.addEventListener("pointermove", move);
|
||
target.addEventListener("pointerup", end);
|
||
target.addEventListener("pointercancel", end);
|
||
}
|
||
|
||
// Outside-click dismissal for a popup: arm() after showing, disarm() after hiding.
|
||
function outsideDismiss(menu, onDismiss, anchor) {
|
||
let handler = null;
|
||
const disarm = () => {
|
||
if (!handler) return;
|
||
document.removeEventListener("pointerdown", handler, true);
|
||
document.removeEventListener("mousedown", handler, true);
|
||
handler = null;
|
||
};
|
||
const arm = () => {
|
||
disarm();
|
||
handler = (e) => { if (!menu.contains(e.target) && e.target !== anchor) onDismiss(); };
|
||
setTimeout(() => { // defer so the opening click itself doesn't dismiss
|
||
if (!handler) return;
|
||
document.addEventListener("pointerdown", handler, true);
|
||
document.addEventListener("mousedown", handler, true);
|
||
}, 0);
|
||
};
|
||
return { arm, disarm };
|
||
}
|
||
|
||
function injectStyle() {
|
||
if (document.getElementById("ai2go-ideo-style")) return;
|
||
const s = document.createElement("style");
|
||
s.id = "ai2go-ideo-style";
|
||
s.textContent = `
|
||
.ai2go-ideo-wrap { display:flex; flex-direction:column; overflow:hidden; position:relative; pointer-events:auto; gap:4px; }
|
||
.ai2go-ideo-cv { flex:1 1 auto; min-width:0; min-height:60px; display:flex; align-items:center; justify-content:center; overflow:hidden; }
|
||
.ai2go-ideo-canvas { cursor:crosshair; display:block; flex:0 0 auto; background:#1a1a1a; border-radius:4px; outline:none; touch-action:none; }
|
||
.ai2go-ideo-bar { display:flex; align-items:center; gap:6px; font:11px sans-serif; color:#aaa; user-select:none; padding:0 2px; flex:0 0 auto; }
|
||
.ai2go-ideo-panel { display:flex; flex-direction:column; gap:5px; padding:6px; background:#262626; border-radius:4px; font:11px sans-serif; color:#bbb; flex:0 0 auto; overflow-y:auto; min-height:0; }
|
||
.ai2go-ideo-split { flex:0 0 auto; height:8px; cursor:ns-resize; position:relative; }
|
||
.ai2go-ideo-split::before { content:""; position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:34px; height:3px; background:#555; border-radius:2px; }
|
||
.ai2go-ideo-split:hover::before { background:#46b4e6; }
|
||
.ai2go-ideo-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||
.ai2go-ideo-btn { background:#333; border:1px solid #555; border-radius:4px; color:#bbb; font:11px sans-serif; cursor:pointer; padding:2px 8px; line-height:16px; white-space:nowrap; flex-shrink:0; }
|
||
.ai2go-ideo-btn:hover { border-color:#46b4e6; color:#fff; }
|
||
.ai2go-ideo-btn.active { border-color:#46b4e6; color:#46b4e6; background:#2a3a42; }
|
||
.ai2go-ideo-area { width:100%; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#ddd; font:13px monospace; padding:4px 6px; resize:none; min-height:36px; flex:1 1 auto; }
|
||
.ai2go-ideo-sw { width:20px; height:20px; border:1px solid #666; border-radius:3px; cursor:pointer; flex-shrink:0; position:relative; touch-action:none; transition:transform .18s ease, box-shadow .12s ease, opacity .12s ease; }
|
||
.ai2go-ideo-sw:hover { transform:scale(1.2); box-shadow:0 0 0 2px #46b4e6; z-index:3; }
|
||
.ai2go-ideo-sw.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; }
|
||
body.ai2go-ideo-dragging, body.ai2go-ideo-dragging * { cursor:move !important; }
|
||
.ai2go-ideo-sw input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||
.ai2go-ideo-inline { position:absolute; box-sizing:border-box; background:rgba(18,18,18,0.92); border:2px solid #46b4e6; border-radius:3px; color:#fff; font:13px monospace; padding:3px 4px; resize:none; outline:none; z-index:10; }
|
||
.ai2go-ideo-bbox { width:128px; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#bbb; font:11px monospace; padding:2px 5px; }
|
||
.ai2go-ideo-bbox:focus { border-color:#46b4e6; outline:none; color:#fff; }
|
||
.ai2go-ideo-menu { position:fixed; z-index:10000; background:#262626; border:1px solid #555; border-radius:6px; padding:4px; box-shadow:0 6px 20px rgba(0,0,0,0.55); font:12px sans-serif; color:#ddd; max-height:60vh; overflow-y:auto; min-width:210px; max-width:340px; }
|
||
.ai2go-ideo-mhdr { font:11px sans-serif; color:#888; padding:2px 6px 4px; user-select:none; }
|
||
.ai2go-ideo-lrow { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:4px; cursor:move; user-select:none; touch-action:none; transition:transform .18s ease, box-shadow .12s ease, opacity .12s ease, background .12s; }
|
||
.ai2go-ideo-lrow:hover { background:#333; }
|
||
.ai2go-ideo-lrow.active { background:#2a3a42; box-shadow:inset 0 0 0 1px #46b4e6; }
|
||
.ai2go-ideo-lrow.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; background:#333; }
|
||
.ai2go-ideo-lsw { width:16px; height:16px; border-radius:3px; border:1px solid #666; flex:0 0 auto; }
|
||
.ai2go-ideo-lnum { font:bold 11px monospace; color:#888; flex:0 0 auto; width:18px; }
|
||
.ai2go-ideo-ltext { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.ai2go-ideo-ltext.empty { color:#777; font-style:italic; }
|
||
.ai2go-ideo-lbtn { background:none; border:none; color:#999; cursor:pointer; font:13px sans-serif; line-height:1; padding:2px 5px; border-radius:3px; flex:0 0 auto; }
|
||
.ai2go-ideo-lbtn:hover { color:#fff; background:#444; }
|
||
.ai2go-ideo-lbtn.del:hover { color:#fff; background:#a33; }
|
||
.ai2go-ideo-lbtn.on { background:#3a3320; }
|
||
.ai2go-ideo-lock { filter:grayscale(1); opacity:0.4; } /* unlocked: faded grey */
|
||
.ai2go-ideo-lock.on, .ai2go-ideo-lock:hover { filter:none; opacity:1; } /* locked / hover: full colour */
|
||
.ai2go-ideo-lbtn:disabled { opacity:0.25; cursor:default; background:none; }
|
||
.ai2go-ideo-fs { position:fixed; inset:0; z-index:9000; background:rgba(0,0,0,0.72); display:flex; align-items:center; justify-content:center; }
|
||
.ai2go-ideo-fs-inner { position:relative; width:88vw; height:90vh; background:#1a1a1a; border:1px solid #444; border-radius:8px; box-shadow:0 12px 48px rgba(0,0,0,0.6); padding:12px; box-sizing:border-box; }
|
||
.ai2go-ideo-fs-inner .ai2go-ideo-wrap { height:100%; }
|
||
.ai2go-ideo-fs-close { position:absolute; top:14px; right:18px; z-index:5; padding:4px 12px; font-size:14px; }
|
||
.ai2go-ideo-dock { position:fixed; z-index:8500; pointer-events:auto; display:flex; flex-direction:column; background:var(--ai2go-dock-bg,#1a1a1a); border:1px solid var(--ai2go-dock-border,#555); border-radius:8px; box-shadow:0 8px 30px rgba(0,0,0,0.55); min-width:${DOCK_MINW}px; min-height:${DOCK_MINH}px; overflow:hidden; }
|
||
.ai2go-ideo-rsz { position:absolute; z-index:20; touch-action:none; }
|
||
.ai2go-ideo-rsz.n { top:0; left:11px; right:11px; height:6px; cursor:ns-resize; }
|
||
.ai2go-ideo-rsz.s { bottom:0; left:11px; right:11px; height:6px; cursor:ns-resize; }
|
||
.ai2go-ideo-rsz.e { right:0; top:11px; bottom:11px; width:6px; cursor:ew-resize; }
|
||
.ai2go-ideo-rsz.w { left:0; top:11px; bottom:11px; width:6px; cursor:ew-resize; }
|
||
.ai2go-ideo-rsz.ne { top:0; right:0; width:12px; height:12px; cursor:nesw-resize; }
|
||
.ai2go-ideo-rsz.nw { top:0; left:0; width:12px; height:12px; cursor:nwse-resize; }
|
||
.ai2go-ideo-rsz.se { bottom:0; right:0; width:12px; height:12px; cursor:nwse-resize; }
|
||
.ai2go-ideo-rsz.sw { bottom:0; left:0; width:12px; height:12px; cursor:nesw-resize; }
|
||
.ai2go-ideo-dock.minimized { min-height:0 !important; height:auto !important; }
|
||
.ai2go-ideo-dock.minimized .ai2go-ideo-dock-body { display:none; }
|
||
.ai2go-ideo-dock.minimized .ai2go-ideo-rsz { display:none; }
|
||
.ai2go-ideo-dock.pinned .ai2go-ideo-rsz { display:none; } /* node pinned → locked, like the node */
|
||
.ai2go-ideo-dock.pinned .ai2go-ideo-dock-head { cursor:default; }
|
||
.ai2go-ideo-dock.snap-ready { box-shadow:0 -3px 0 0 #46b4e6, 0 8px 30px rgba(0,0,0,0.55); } /* upper border lit = release to snap */
|
||
.ai2go-ideo-dock-head { display:flex; align-items:center; gap:6px; padding:4px 8px; background:var(--ai2go-dock-head,#262626); cursor:move; font:12px sans-serif; color:#ccc; user-select:none; border-bottom:1px solid rgba(0,0,0,0.25); flex:0 0 auto; }
|
||
.ai2go-ideo-dock-head .ai2go-ideo-btn { padding:1px 7px; }
|
||
.ai2go-ideo-dock-body { flex:1 1 auto; min-height:0; padding:8px; box-sizing:border-box; overflow:hidden; }
|
||
.ai2go-ideo-dock-body .ai2go-ideo-wrap { height:100%; }
|
||
.ai2go-ideo-bgmenu { padding:7px; display:flex; flex-direction:column; gap:7px; min-width:170px; }
|
||
.ai2go-ideo-bgrow { display:flex; align-items:center; gap:8px; }
|
||
.ai2go-ideo-bglbl { color:#888; font:11px sans-serif; flex:0 0 auto; min-width:62px; }
|
||
.ai2go-ideo-trow { padding:2px 4px; border-radius:4px; }
|
||
.ai2go-ideo-trow:hover { background:#333; }
|
||
.ai2go-ideo-dragpill { position:fixed; z-index:11000; pointer-events:none; background:#2a3a42; color:#cfe8f5; border:1px solid #46b4e6; border-radius:5px; padding:2px 8px; font:11px sans-serif; box-shadow:0 4px 14px rgba(0,0,0,0.5); white-space:nowrap; }
|
||
.ai2go-ideo-cvrow { display:flex; flex:1 1 auto; min-height:60px; gap:4px; overflow:hidden; }
|
||
.ai2go-ideo-explorer { flex:0 0 auto; display:flex; flex-direction:column; background:#202020; border:1px solid #333; border-radius:4px; overflow:hidden; min-width:0; }
|
||
.ai2go-ideo-explorer.collapsed { width:26px !important; }
|
||
.ai2go-ideo-exhead { display:flex; align-items:center; gap:4px; padding:3px 4px; background:#2a2a2a; font:11px sans-serif; color:#aaa; user-select:none; flex:0 0 auto; }
|
||
.ai2go-ideo-extitle { flex:1 1 auto; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.ai2go-ideo-explorer.collapsed .ai2go-ideo-extitle, .ai2go-ideo-explorer.collapsed .ai2go-ideo-exlist { display:none; }
|
||
.ai2go-ideo-exlist { flex:1 1 auto; overflow-y:auto; overflow-x:hidden; min-height:0; padding:3px; display:flex; flex-direction:column; gap:1px; }
|
||
.ai2go-ideo-exrsz { flex:0 0 auto; width:5px; cursor:ew-resize; align-self:stretch; border-radius:2px; }
|
||
.ai2go-ideo-exrsz:hover { background:#46b4e6; }
|
||
.ai2go-ideo-extoggle { background:none; border:none; color:#aaa; cursor:pointer; font:12px sans-serif; line-height:1; padding:2px 4px; flex:0 0 auto; }
|
||
.ai2go-ideo-extoggle:hover { color:#fff; }
|
||
.ai2go-ideo-exptri { flex:0 0 auto; width:13px; text-align:center; color:#888; cursor:pointer; font:9px monospace; user-select:none; }
|
||
.ai2go-ideo-exptri:hover { color:#fff; }
|
||
.ai2go-ideo-exptri.leaf { cursor:default; color:#444; }
|
||
.ai2go-ideo-lrow.drop-into { box-shadow:inset 0 0 0 2px #46b4e6; background:#2a3a42; }
|
||
.ai2go-ideo-lrow.disabled { opacity:0.5; } /* whole row dims when hidden (own or via its group) */
|
||
.ai2go-ideo-en { padding:2px 4px; }
|
||
.ai2go-ideo-exhead.drop-root { box-shadow:inset 0 0 0 2px #46b4e6; }
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "AI2Go.Ideogram4PromptBuilder",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
if (nodeData?.name !== "AI2GoIdeogram4PromptBuilder") return;
|
||
injectStyle();
|
||
|
||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||
const node = this;
|
||
const findW = (n) => node.widgets?.find((w) => w.name === n);
|
||
const elementsWidget = findW("elements_data");
|
||
const stylePaletteWidget = findW("style_palette_data");
|
||
const bgBrightnessWidget = findW("bg_brightness");
|
||
const formatWidget = findW("output_format"); // "pretty" | "compact" (set via the toolbar)
|
||
const coordWidget = findW("coord_mode"); // "normalized" | "absolute" (set via the toolbar)
|
||
const orderWidget = findW("bbox_order"); // "yx" (Ideogram) | "xy" (Qwen/standard) (set via the toolbar)
|
||
if (bgBrightnessWidget && typeof bgBrightnessWidget.value !== "number") bgBrightnessWidget.value = 25;
|
||
const wWidget = findW("width"), hWidget = findW("height");
|
||
// Hide the data widgets while keeping them serializable.
|
||
function hideDataWidgets() {
|
||
for (const w of [elementsWidget, stylePaletteWidget, bgBrightnessWidget, formatWidget, coordWidget, orderWidget]) {
|
||
if (!w) continue;
|
||
w.hidden = true;
|
||
w.computeSize = () => [0, -4];
|
||
}
|
||
for (const name of ["elements_data", "style_palette_data", "bg_brightness", "output_format", "coord_mode", "bbox_order"]) {
|
||
const i = node.inputs?.findIndex((inp) => inp.name === name);
|
||
if (i != null && i !== -1) node.removeInput(i);
|
||
}
|
||
}
|
||
hideDataWidgets();
|
||
|
||
node._boxes = []; // {x,y,w,h normalized 0-1, type, text, desc, palette[]}
|
||
node._stylePalette = []; // global style color palette (hex[])
|
||
node._activeIdx = -1;
|
||
node._drawing = false;
|
||
node._placing = false; // duplicate-placement mode: active box follows the cursor until clicked
|
||
node._dragMode = null;
|
||
node._dragStartN = null; // mouse-down point, normalized
|
||
node._boxAtStart = null; // active box snapshot at drag start
|
||
node._selection = new Set(); // selected box indices (multi-select); always contains _activeIdx
|
||
node._groupStart = null; // {idx: {x,y,w,h}} snapshot of all selected boxes at drag start
|
||
node._pendingCollapse = -1; // box to collapse selection to if a click (not drag) on a multi-selection
|
||
node._marquee = null; // {x0,y0,x,y} rubber-band selection rect (shift-drag), normalized
|
||
node._hoverTitle = null; // index of the title chip under the cursor
|
||
node._hoverBox = null; // index of the box under the cursor
|
||
node._focused = false; // editor (DOM) focused — gates the active-box highlight
|
||
node._selected = false; // node selected in the graph
|
||
node._bgImg = null; // optional reference image shown as the canvas background
|
||
node._bgManual = false; // bg set via "use last result" (not the image input)
|
||
node._lastImported = ""; // last import_json applied to the editor (avoid re-apply)
|
||
node._areaObservers = []; // (reserved) live ResizeObservers to disconnect on rebuild
|
||
|
||
// ── DOM ──
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "ai2go-ideo-wrap";
|
||
const bar = document.createElement("div");
|
||
bar.className = "ai2go-ideo-bar";
|
||
const hint = document.createElement("span");
|
||
hint.style.flex = "1";
|
||
const copyBtn = document.createElement("button");
|
||
copyBtn.className = "ai2go-ideo-btn";
|
||
copyBtn.textContent = "Copy";
|
||
copyBtn.title = "Copy the current caption JSON to the clipboard";
|
||
const importBtn = document.createElement("button");
|
||
importBtn.className = "ai2go-ideo-btn";
|
||
importBtn.textContent = "Paste";
|
||
importBtn.title = "Parse a caption JSON (clipboard, else paste prompt) and populate the node";
|
||
const clearCanvasBtn = document.createElement("button");
|
||
clearCanvasBtn.className = "ai2go-ideo-btn";
|
||
clearCanvasBtn.textContent = "Clear canvas";
|
||
clearCanvasBtn.title = "Remove all boxes from the canvas — leaves the caption text fields and style palette untouched";
|
||
const clearBtn = document.createElement("button");
|
||
clearBtn.className = "ai2go-ideo-btn";
|
||
clearBtn.textContent = "Clear all";
|
||
clearBtn.title = "Reset the whole node — boxes, style palette, and every caption text field";
|
||
const tokenSpan = document.createElement("span");
|
||
tokenSpan.style.cssText = "color:#888; white-space:nowrap;";
|
||
tokenSpan.title = "Rough token estimate (~chars/4). Grey <256, green healthy, orange nearing, red ≥2048 (model cap — will error)";
|
||
// Output-settings dropdown (formatting + bbox coordinate space/order) — writes the hidden widgets.
|
||
const outBtn = document.createElement("button");
|
||
outBtn.className = "ai2go-ideo-btn"; outBtn.textContent = "Output ▾";
|
||
outBtn.title = "Output JSON settings: compact/pretty, bbox coordinate space and axis order";
|
||
stopProp(outBtn);
|
||
const outToggle = (get, set, label, title) => { // checkbox row bound to a hidden widget
|
||
const l = document.createElement("label");
|
||
l.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;"; l.title = title;
|
||
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = get();
|
||
stopProp(cb);
|
||
cb.addEventListener("change", () => { set(cb.checked); updateTokens(); flushChange(); });
|
||
l.appendChild(cb); l.appendChild(document.createTextNode(label));
|
||
l._cb = cb; return l;
|
||
};
|
||
const compactLbl = outToggle(
|
||
() => (formatWidget?.value) !== "pretty",
|
||
(on) => { if (formatWidget) formatWidget.value = on ? "compact" : "pretty"; },
|
||
"compact JSON", "Compact output (the format Ideogram 4 expects). Uncheck for pretty/indented.");
|
||
const absLbl = outToggle(
|
||
() => (coordWidget?.value) === "absolute",
|
||
(on) => { if (coordWidget) coordWidget.value = on ? "absolute" : "normalized"; },
|
||
"absolute pixels", "bbox in absolute pixels (scaled by width/height) instead of the 0-1000 grid. NON-STANDARD for Ideogram 4.");
|
||
const xyLbl = outToggle(
|
||
() => (orderWidget?.value) === "xy",
|
||
(on) => { if (orderWidget) orderWidget.value = on ? "xy" : "yx"; },
|
||
"xy order (Qwen)", "bbox axis order [xmin,ymin,xmax,ymax] (x1,y1,x2,y2, as Qwen-VL uses) instead of Ideogram's [ymin,xmin,ymax,xmax].");
|
||
const outMenu = document.createElement("div");
|
||
outMenu.className = "ai2go-ideo-menu ai2go-ideo-bgmenu";
|
||
outMenu.style.display = "none";
|
||
for (const l of [compactLbl, absLbl, xyLbl]) { const r = document.createElement("div"); r.className = "ai2go-ideo-bgrow"; r.appendChild(l); outMenu.appendChild(r); }
|
||
document.body.appendChild(outMenu);
|
||
node._outMenu = outMenu;
|
||
const outDismiss = outsideDismiss(outMenu, () => closeOutMenu(), outBtn);
|
||
function closeOutMenu() { outMenu.style.display = "none"; outDismiss.disarm(); }
|
||
outBtn.addEventListener("click", () => {
|
||
if (outMenu.style.display !== "none") { closeOutMenu(); return; }
|
||
outMenu.style.display = "";
|
||
const r = outBtn.getBoundingClientRect();
|
||
outMenu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - outMenu.offsetWidth - 4)) + "px";
|
||
outMenu.style.top = Math.min(r.bottom + 4, window.innerHeight - outMenu.offsetHeight - 4) + "px";
|
||
outDismiss.arm();
|
||
});
|
||
const grabBtn = document.createElement("button");
|
||
grabBtn.className = "ai2go-ideo-btn";
|
||
grabBtn.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
grabBtn.addEventListener("click", () => { (node._bgManual && node._bgImg) ? node._clearBg() : node._grabResultBg(); });
|
||
function updateGrabBtn() {
|
||
const clear = node._bgManual && node._bgImg;
|
||
grabBtn.textContent = clear ? "Clear BG" : "Grab BG";
|
||
grabBtn.title = clear ? "Remove the grabbed background"
|
||
: "Use the last generated image as the background";
|
||
}
|
||
const liveLabel = document.createElement("label");
|
||
liveLabel.style.cssText = "display:flex;align-items:center;gap:3px;flex:0 0 auto;cursor:pointer;";
|
||
liveLabel.title = "Use the live sampling preview as the background while generating";
|
||
const liveChk = document.createElement("input");
|
||
liveChk.type = "checkbox";
|
||
liveChk.checked = !!node.properties.liveBg;
|
||
liveChk.addEventListener("mousedown", (e) => e.stopPropagation());
|
||
liveChk.addEventListener("change", () => {
|
||
node.properties.liveBg = liveChk.checked;
|
||
if (liveChk.checked) livePreviewNodes.add(node); else livePreviewNodes.delete(node);
|
||
});
|
||
liveLabel.appendChild(liveChk); liveLabel.appendChild(document.createTextNode("Live"));
|
||
if (liveChk.checked) livePreviewNodes.add(node);
|
||
const bgSlider = document.createElement("input");
|
||
bgSlider.type = "range"; bgSlider.min = "0"; bgSlider.max = "100"; bgSlider.step = "1";
|
||
bgSlider.value = bgBrightnessWidget ? bgBrightnessWidget.value : 25;
|
||
bgSlider.title = "Background brightness (image or blank canvas)";
|
||
bgSlider.style.cssText = "width:64px;flex:0 0 auto;";
|
||
stopProp(bgSlider);
|
||
bgSlider.addEventListener("input", () => { if (bgBrightnessWidget) bgBrightnessWidget.value = parseInt(bgSlider.value, 10); drawCanvas(); });
|
||
const guideSel = document.createElement("select");
|
||
guideSel.className = "ai2go-ideo-btn"; guideSel.style.cssText = "flex:0 0 auto;";
|
||
guideSel.title = "Composition guide overlay (editor view only)";
|
||
for (const [val, label] of [["none", "no guide"], ["thirds", "thirds"], ["grid", "grid"], ["golden", "golden ratio"], ["spiral", "golden spiral"]]) {
|
||
const o = document.createElement("option"); o.value = val; o.textContent = label; guideSel.appendChild(o);
|
||
}
|
||
guideSel.value = node.properties.guide || "none";
|
||
stopProp(guideSel);
|
||
guideSel.addEventListener("change", () => { node.properties.guide = guideSel.value; drawCanvas(); });
|
||
// Group the background/guide controls into one popup to keep the toolbar tidy.
|
||
const bgBtn = document.createElement("button");
|
||
bgBtn.className = "ai2go-ideo-btn"; bgBtn.textContent = "Background ▾";
|
||
bgBtn.title = "Background & guides: live preview, grab/clear, brightness, composition guide";
|
||
stopProp(bgBtn);
|
||
const GRID_INV = 130; // slider shows cell SIZE: position = GRID_INV - divisions (right = larger cells)
|
||
const gridSlider = document.createElement("input");
|
||
gridSlider.type = "range"; gridSlider.min = "2"; gridSlider.max = "128"; gridSlider.step = "1";
|
||
gridSlider.value = GRID_INV - (node.properties.gridSize || 10);
|
||
gridSlider.style.cssText = "width:90px;flex:0 0 auto;";
|
||
gridSlider.title = "Grid cell size (drag right for larger cells); also the snap step";
|
||
stopProp(gridSlider);
|
||
gridSlider.addEventListener("input", () => { node.properties.gridSize = GRID_INV - parseInt(gridSlider.value, 10); drawCanvas(); });
|
||
const snapLabel = document.createElement("label");
|
||
snapLabel.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;";
|
||
const snapChk = document.createElement("input");
|
||
snapChk.type = "checkbox"; snapChk.checked = !!node.properties.snap;
|
||
snapChk.addEventListener("change", () => { node.properties.snap = snapChk.checked; });
|
||
snapLabel.appendChild(snapChk); snapLabel.appendChild(document.createTextNode("snap to grid"));
|
||
const guideColor = document.createElement("input");
|
||
guideColor.type = "color"; guideColor.value = node.properties.guideColor || "#ffffff";
|
||
guideColor.style.cssText = "width:32px;height:20px;flex:0 0 auto;padding:0;border:1px solid #555;background:none;";
|
||
stopProp(guideColor);
|
||
guideColor.addEventListener("input", () => { node.properties.guideColor = guideColor.value; drawCanvas(); });
|
||
const opacitySlider = document.createElement("input");
|
||
opacitySlider.type = "range"; opacitySlider.min = "0"; opacitySlider.max = "100"; opacitySlider.step = "1";
|
||
opacitySlider.value = node.properties.guideOpacity == null ? 100 : node.properties.guideOpacity;
|
||
opacitySlider.style.cssText = "width:90px;flex:0 0 auto;";
|
||
opacitySlider.title = "Guide/grid line opacity";
|
||
stopProp(opacitySlider);
|
||
opacitySlider.addEventListener("input", () => { node.properties.guideOpacity = parseInt(opacitySlider.value, 10); drawCanvas(); });
|
||
const bgMenu = document.createElement("div");
|
||
bgMenu.className = "ai2go-ideo-menu ai2go-ideo-bgmenu";
|
||
bgMenu.style.display = "none";
|
||
const bgRow = (labelText, el) => {
|
||
const r = document.createElement("div"); r.className = "ai2go-ideo-bgrow";
|
||
if (labelText) { const l = document.createElement("span"); l.className = "ai2go-ideo-bglbl"; l.textContent = labelText; r.appendChild(l); }
|
||
r.appendChild(el); bgMenu.appendChild(r);
|
||
};
|
||
bgRow("", liveLabel); bgRow("", grabBtn); bgRow("Brightness", bgSlider);
|
||
bgRow("Guide", guideSel); bgRow("Grid size", gridSlider); bgRow("", snapLabel);
|
||
bgRow("Line color", guideColor); bgRow("Line opacity", opacitySlider);
|
||
document.body.appendChild(bgMenu);
|
||
node._bgMenu = bgMenu;
|
||
const bgDismiss = outsideDismiss(bgMenu, () => closeBgMenu(), bgBtn);
|
||
function closeBgMenu() { bgMenu.style.display = "none"; bgDismiss.disarm(); }
|
||
bgBtn.addEventListener("click", () => {
|
||
if (bgMenu.style.display !== "none") { closeBgMenu(); return; }
|
||
bgMenu.style.display = "";
|
||
const r = bgBtn.getBoundingClientRect();
|
||
bgMenu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - bgMenu.offsetWidth - 4)) + "px";
|
||
bgMenu.style.top = Math.min(r.bottom + 4, window.innerHeight - bgMenu.offsetHeight - 4) + "px";
|
||
bgDismiss.arm();
|
||
});
|
||
// ── Text style popup: show/hide, outline, font size, auto-placement ──
|
||
const txtBtn = document.createElement("button");
|
||
txtBtn.className = "ai2go-ideo-btn"; txtBtn.textContent = "Text ▾";
|
||
txtBtn.title = "Region text: show/hide, outline, font size, overlap-avoiding placement";
|
||
stopProp(txtBtn);
|
||
const txtToggle = (prop, label, title) => {
|
||
const l = document.createElement("label");
|
||
l.style.cssText = "display:flex;align-items:center;gap:4px;cursor:pointer;"; l.title = title || "";
|
||
const cb = document.createElement("input"); cb.type = "checkbox"; cb.checked = node.properties[prop] !== false;
|
||
cb.addEventListener("change", () => { node.properties[prop] = cb.checked; drawCanvas(); });
|
||
l.appendChild(cb); l.appendChild(document.createTextNode(label));
|
||
l._cb = cb; return l;
|
||
};
|
||
const showLbl = txtToggle("showBoxText", "show text", "Draw each region's text inside its box");
|
||
const strokeLbl = txtToggle("textStroke", "outline", "Dark halo behind the text for legibility");
|
||
const autoLbl = txtToggle("textAutoPlace", "auto-place", "Stagger overlapping labels to reduce overlap");
|
||
const sizeSlider = document.createElement("input");
|
||
sizeSlider.type = "range"; sizeSlider.min = "8"; sizeSlider.max = "22"; sizeSlider.step = "1";
|
||
sizeSlider.value = node.properties.textSize || 12;
|
||
sizeSlider.style.cssText = "width:90px;flex:0 0 auto;";
|
||
sizeSlider.title = "Region text font size";
|
||
stopProp(sizeSlider);
|
||
sizeSlider.addEventListener("input", () => { node.properties.textSize = parseInt(sizeSlider.value, 10); drawCanvas(); });
|
||
const boxOpacSlider = document.createElement("input");
|
||
boxOpacSlider.type = "range"; boxOpacSlider.min = "0"; boxOpacSlider.max = "100"; boxOpacSlider.step = "1";
|
||
boxOpacSlider.value = node.properties.boxOpacity == null ? 14 : node.properties.boxOpacity;
|
||
boxOpacSlider.style.cssText = "width:90px;flex:0 0 auto;";
|
||
boxOpacSlider.title = "Box fill opacity";
|
||
stopProp(boxOpacSlider);
|
||
boxOpacSlider.addEventListener("input", () => { node.properties.boxOpacity = parseInt(boxOpacSlider.value, 10); drawCanvas(); });
|
||
const txtMenu = document.createElement("div");
|
||
txtMenu.className = "ai2go-ideo-menu ai2go-ideo-bgmenu";
|
||
txtMenu.style.display = "none";
|
||
const txtRow = (labelText, el) => {
|
||
const r = document.createElement("div"); r.className = "ai2go-ideo-bgrow";
|
||
if (labelText) { const l = document.createElement("span"); l.className = "ai2go-ideo-bglbl"; l.textContent = labelText; r.appendChild(l); }
|
||
r.appendChild(el); txtMenu.appendChild(r);
|
||
};
|
||
txtRow("", showLbl); txtRow("Font size", sizeSlider); txtRow("Box opacity", boxOpacSlider); txtRow("", strokeLbl); txtRow("", autoLbl);
|
||
document.body.appendChild(txtMenu);
|
||
node._txtMenu = txtMenu;
|
||
const txtDismiss = outsideDismiss(txtMenu, () => closeTxtMenu(), txtBtn);
|
||
function closeTxtMenu() { txtMenu.style.display = "none"; txtDismiss.disarm(); }
|
||
txtBtn.addEventListener("click", () => {
|
||
if (txtMenu.style.display !== "none") { closeTxtMenu(); return; }
|
||
txtMenu.style.display = "";
|
||
const r = txtBtn.getBoundingClientRect();
|
||
txtMenu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - txtMenu.offsetWidth - 4)) + "px";
|
||
txtMenu.style.top = Math.min(r.bottom + 4, window.innerHeight - txtMenu.offsetHeight - 4) + "px";
|
||
txtDismiss.arm();
|
||
});
|
||
// ── Templates popup: save/load named caption JSONs (server-side userdata) ──
|
||
const tplBtn = document.createElement("button");
|
||
tplBtn.className = "ai2go-ideo-btn"; tplBtn.textContent = "Templates ▾";
|
||
tplBtn.title = "Save / load the caption JSON as templates (stored on the server ComfyUI\\user\\default\\ai2go\\ideogram4)";
|
||
stopProp(tplBtn);
|
||
const tplMenu = document.createElement("div");
|
||
tplMenu.className = "ai2go-ideo-menu ai2go-ideo-bgmenu";
|
||
tplMenu.style.display = "none";
|
||
document.body.appendChild(tplMenu);
|
||
node._tplMenu = tplMenu;
|
||
const tplDismiss = outsideDismiss(tplMenu, () => closeTplMenu(), tplBtn);
|
||
function closeTplMenu() { tplMenu.style.display = "none"; tplDismiss.disarm(); }
|
||
async function buildTplMenu() {
|
||
tplMenu.innerHTML = "";
|
||
const saveRow = document.createElement("div"); saveRow.className = "ai2go-ideo-bgrow";
|
||
const saveBtn = document.createElement("button"); saveBtn.className = "ai2go-ideo-btn"; saveBtn.textContent = "+ Save as…";
|
||
saveBtn.addEventListener("click", async () => {
|
||
const name = tplSafe(window.prompt("Save template as:", "") || "");
|
||
if (!name) return;
|
||
const existing = await listTemplateNames();
|
||
if (existing.includes(name) && !window.confirm(`Overwrite template "${name}"?`)) return;
|
||
if (await saveTemplate(name, buildClipboardCaption())) buildTplMenu();
|
||
});
|
||
saveRow.appendChild(saveBtn); tplMenu.appendChild(saveRow);
|
||
const names = await listTemplateNames();
|
||
if (!names.length) {
|
||
const empty = document.createElement("div"); empty.className = "ai2go-ideo-mhdr"; empty.textContent = "No templates saved.";
|
||
tplMenu.appendChild(empty);
|
||
}
|
||
for (const name of names) {
|
||
const row = document.createElement("div"); row.className = "ai2go-ideo-bgrow ai2go-ideo-trow";
|
||
const txt = document.createElement("span");
|
||
txt.className = "ai2go-ideo-ltext"; txt.style.cssText = "flex:1 1 auto;cursor:pointer;"; txt.textContent = name; txt.title = "Load " + name + " (replaces everything)";
|
||
const ins = document.createElement("button"); ins.className = "ai2go-ideo-lbtn"; ins.textContent = "⊞"; ins.title = "Insert this template's boxes only into the current canvas";
|
||
const upd = document.createElement("button"); upd.className = "ai2go-ideo-lbtn"; upd.textContent = "⤓"; upd.title = "Save current (overwrite)";
|
||
const del = document.createElement("button"); del.className = "ai2go-ideo-lbtn del"; del.textContent = "✕"; del.title = "Delete template";
|
||
row.append(txt, ins, upd, del); tplMenu.appendChild(row);
|
||
txt.addEventListener("click", async () => {
|
||
const cap = tryParseCaption(await loadTemplate(name));
|
||
if (!cap) { window.alert("That template isn't a valid caption JSON."); return; }
|
||
loadCaption(cap); closeTplMenu();
|
||
});
|
||
ins.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
const cap = tryParseCaption(await loadTemplate(name));
|
||
if (!cap) { window.alert("That template isn't a valid caption JSON."); return; }
|
||
insertCaptionBoxes(cap); closeTplMenu();
|
||
});
|
||
upd.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (await saveTemplate(name, buildClipboardCaption())) buildTplMenu();
|
||
});
|
||
del.addEventListener("click", async (e) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(`Delete template "${name}"?`)) return;
|
||
await deleteTemplate(name); buildTplMenu();
|
||
});
|
||
}
|
||
}
|
||
tplBtn.addEventListener("click", async () => {
|
||
if (tplMenu.style.display !== "none") { closeTplMenu(); return; }
|
||
tplMenu.style.display = "";
|
||
await buildTplMenu();
|
||
const r = tplBtn.getBoundingClientRect();
|
||
tplMenu.style.left = Math.max(4, Math.min(r.left, window.innerWidth - tplMenu.offsetWidth - 4)) + "px";
|
||
tplMenu.style.top = Math.min(r.bottom + 4, window.innerHeight - tplMenu.offsetHeight - 4) + "px";
|
||
tplDismiss.arm();
|
||
});
|
||
// Toggle on-canvas visibility of group frames (the Overview tree always shows them).
|
||
const groupsBtn = document.createElement("button");
|
||
groupsBtn.className = "ai2go-ideo-btn"; groupsBtn.textContent = "👁️";
|
||
stopProp(groupsBtn);
|
||
function syncGroupsBtn() {
|
||
const shown = node.properties.showGroups === true; // hidden by default; only `true` shows the frames
|
||
groupsBtn.classList.toggle("active", shown);
|
||
groupsBtn.title = shown ? "Group frames visible on the canvas — click to hide (they stay in the Overview)"
|
||
: "Group frames hidden on the canvas — click to show (they're in the Overview)";
|
||
}
|
||
groupsBtn.addEventListener("click", () => { node.properties.showGroups = (node.properties.showGroups !== true); syncGroupsBtn(); drawCanvas(); flushChange(); });
|
||
syncGroupsBtn();
|
||
bar.appendChild(hint); bar.appendChild(tokenSpan); bar.appendChild(outBtn); bar.appendChild(bgBtn); bar.appendChild(txtBtn); bar.appendChild(groupsBtn); bar.appendChild(copyBtn); bar.appendChild(importBtn); bar.appendChild(tplBtn); bar.appendChild(clearCanvasBtn); bar.appendChild(clearBtn);
|
||
updateGrabBtn();
|
||
|
||
// Persistent global style-palette row
|
||
const styleBar = document.createElement("div");
|
||
styleBar.className = "ai2go-ideo-bar";
|
||
const styleLbl = document.createElement("span");
|
||
styleLbl.textContent = "Style colors:";
|
||
styleBar.appendChild(styleLbl);
|
||
|
||
const canvasEl = document.createElement("canvas");
|
||
canvasEl.className = "ai2go-ideo-canvas";
|
||
canvasEl.tabIndex = 0; // focusable, so it can receive key events
|
||
canvasEl.title = "Drag to draw · Ctrl-drag force-draw over a box · click to select · shift-drag marquee-select · " +
|
||
"shift-click toggle · drag a group to move all · alt-click overlap · dbl-click edit · right-click region list · " +
|
||
"Del remove (all selected) · Ctrl/Cmd+C/V/D copy/paste/duplicate · H hide boxes (view)";
|
||
const ctx = canvasEl.getContext("2d");
|
||
addWheelPassthrough(wrap); // middle-click pan is wired on the whole dock (fl) in undock()
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "ai2go-ideo-panel";
|
||
node._panelH = node._panelH || 150; // height of the prompt panel (set by the splitter)
|
||
panel.style.height = node._panelH + "px";
|
||
|
||
// Canvas letterbox-fits into a flex container (cvBox), so node resize never aspect-locks the height.
|
||
const cvBox = document.createElement("div");
|
||
cvBox.className = "ai2go-ideo-cv";
|
||
cvBox.appendChild(canvasEl);
|
||
|
||
// ── Object Explorer: a persistent, collapsible list of every region (Unity-style scene panel).
|
||
// Lives left of the canvas; click a row to select (two-way with the canvas) so you can pick/edit
|
||
// a region without dragging it by accident. renderExplorer() (defined below) fills exList.
|
||
const explorerEl = document.createElement("div");
|
||
explorerEl.className = "ai2go-ideo-explorer";
|
||
explorerEl.style.width = (node.properties.explorerW || 190) + "px";
|
||
const exHead = document.createElement("div");
|
||
exHead.className = "ai2go-ideo-exhead";
|
||
const exToggle = document.createElement("button");
|
||
exToggle.className = "ai2go-ideo-extoggle";
|
||
stopProp(exToggle);
|
||
const exTitle = document.createElement("span");
|
||
exTitle.className = "ai2go-ideo-extitle";
|
||
exTitle.textContent = "Overview";
|
||
exTitle.title = "Click a region to select · Ctrl/Cmd-click to multi-select · Shift-click for a range · drag a row onto another to nest";
|
||
const exList = document.createElement("div");
|
||
exList.className = "ai2go-ideo-exlist";
|
||
stopProp(exList);
|
||
exList.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
const row = e.target.closest ? e.target.closest(".ai2go-ideo-lrow") : null;
|
||
openOverviewMenu(e.clientX, e.clientY, row ? row._box : null);
|
||
});
|
||
function applyExplorerCollapsed(on) {
|
||
node.properties.explorerCollapsed = !!on;
|
||
explorerEl.classList.toggle("collapsed", !!on);
|
||
exToggle.textContent = on ? "▸" : "▾";
|
||
exToggle.title = on ? "Expand the region list" : "Collapse the region list";
|
||
if (exRsz) exRsz.style.display = on ? "none" : ""; // no resizing while collapsed (width:26px !important would ignore the drag)
|
||
requestAnimationFrame(fitCanvas);
|
||
}
|
||
exToggle.addEventListener("click", () => { applyExplorerCollapsed(!node.properties.explorerCollapsed); flushChange(); });
|
||
exHead.append(exToggle, exTitle);
|
||
explorerEl.append(exHead, exList);
|
||
// Drag the divider to resize the explorer; the width persists in node.properties.
|
||
const exRsz = document.createElement("div");
|
||
exRsz.className = "ai2go-ideo-exrsz";
|
||
exRsz.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const scale = (node.properties.dockPinned && app.canvas) ? (app.canvas.ds.scale || 1) : 1;
|
||
const sx = e.clientX, w0 = explorerEl.offsetWidth;
|
||
dragPointer(e, exRsz, (me) => {
|
||
const w = Math.max(120, Math.min(w0 + (me.clientX - sx) / scale, cvRow.clientWidth - 140 - exRsz.offsetWidth - 8));
|
||
node.properties.explorerW = Math.round(w); explorerEl.style.width = node.properties.explorerW + "px";
|
||
fitCanvas();
|
||
}, flushChange);
|
||
});
|
||
applyExplorerCollapsed(!!node.properties.explorerCollapsed); // initial state (exRsz now exists, so the resizer toggles too)
|
||
// Canvas + explorer share a horizontal row; cvBox stays flex:1 so fitCanvas() adapts to the rail width.
|
||
const cvRow = document.createElement("div");
|
||
cvRow.className = "ai2go-ideo-cvrow";
|
||
cvRow.append(explorerEl, exRsz, cvBox);
|
||
|
||
// Draggable separator between the canvas and the prompt panel — drag up for more prompt, down for more canvas.
|
||
const splitter = document.createElement("div");
|
||
splitter.className = "ai2go-ideo-split";
|
||
splitter.title = "Drag to resize the description area";
|
||
splitter.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const scale = (node.properties.dockPinned && app.canvas) ? (app.canvas.ds.scale || 1) : 1;
|
||
const sy = e.clientY, h0 = panel.offsetHeight;
|
||
dragPointer(e, splitter, (me) => {
|
||
const dy = (me.clientY - sy) / scale;
|
||
const h = Math.max(60, Math.min(h0 - dy, wrap.clientHeight - 110)); // up → bigger panel; keep room for the canvas
|
||
node._panelH = Math.round(h); panel.style.height = node._panelH + "px"; // persisted via the onSerialize blob
|
||
fitCanvas();
|
||
}, flushChange);
|
||
});
|
||
|
||
wrap.appendChild(bar); wrap.appendChild(styleBar); wrap.appendChild(cvRow); wrap.appendChild(splitter); wrap.appendChild(panel);
|
||
|
||
// The editor is NOT a node widget — it lives in the floating dock (built in undock()), so the node
|
||
// is an ordinary widgets-only node that ComfyUI sizes/persists with zero custom height handling.
|
||
node.resizable = true;
|
||
|
||
// The dock canvas measures 0 while hidden; re-letterbox it when the editor element re-enters view.
|
||
try {
|
||
node._visObserver = new IntersectionObserver((entries) => {
|
||
if (entries.some((en) => en.isIntersecting)) fitCanvas();
|
||
});
|
||
node._visObserver.observe(wrap);
|
||
} catch (e) {}
|
||
|
||
// Letterbox-fit the canvas (keeping width/height aspect) into its flex container cvBox.
|
||
function fitCanvas() {
|
||
if (wrap.offsetParent === null) return; // hidden (collapsed tab / minimized) — measurements are 0
|
||
const availW = cvBox.clientWidth, availH = cvBox.clientHeight;
|
||
if (availW < 4 || availH < 4) return;
|
||
const aspect = (wWidget?.value || 1) / (hWidget?.value || 1);
|
||
let cw = availW, ch = cw / aspect;
|
||
if (ch > availH) { ch = availH; cw = ch * aspect; }
|
||
canvasEl.style.width = Math.round(cw) + "px";
|
||
canvasEl.style.height = Math.round(ch) + "px";
|
||
drawCanvas();
|
||
}
|
||
const fitFsCanvas = fitCanvas; // dock/fullscreen use the same fitter
|
||
function syncCanvasToDims() { fitCanvas(); } // aspect changed (width/height widgets)
|
||
function detachInto(container) { // move the editor element into a container (dock body / fullscreen)
|
||
node._wrapHome = wrap.parentNode;
|
||
container.appendChild(wrap);
|
||
node._fsInner = container;
|
||
}
|
||
function reattach() { // fullscreen exit → back into the floating dock
|
||
canvasEl.style.width = ""; canvasEl.style.height = "";
|
||
const home = node._dockBody || node._wrapHome;
|
||
if (home) home.appendChild(wrap);
|
||
node._fsInner = node._dockBody || null; // stays detached, living in the dock
|
||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||
requestAnimationFrame(fitCanvas);
|
||
}
|
||
|
||
function onFsEsc(e) { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); exitFs(); } }
|
||
function enterFs() {
|
||
if (node._fullscreen) return;
|
||
node._fullscreen = true;
|
||
const ov = document.createElement("div"); ov.className = "ai2go-ideo-fs";
|
||
const inner = document.createElement("div"); inner.className = "ai2go-ideo-fs-inner";
|
||
ov.appendChild(inner);
|
||
ov.addEventListener("mousedown", (e) => { if (e.target === ov) exitFs(); }); // backdrop closes
|
||
document.body.appendChild(ov);
|
||
node._fsOverlay = ov;
|
||
detachInto(inner);
|
||
const closeBtn = document.createElement("button"); // visible exit (⛶ in the dock header is hidden here)
|
||
closeBtn.className = "ai2go-ideo-btn ai2go-ideo-fs-close"; closeBtn.textContent = "✕"; closeBtn.title = "Close (Esc)";
|
||
stopProp(closeBtn); closeBtn.addEventListener("click", exitFs);
|
||
ov.appendChild(closeBtn); // on the backdrop corner, clear of the editor
|
||
document.addEventListener("keydown", onFsEsc, true);
|
||
window.addEventListener("resize", fitFsCanvas);
|
||
requestAnimationFrame(fitFsCanvas);
|
||
}
|
||
function exitFs() {
|
||
if (!node._fullscreen) return;
|
||
node._fullscreen = false;
|
||
document.removeEventListener("keydown", onFsEsc, true);
|
||
window.removeEventListener("resize", fitFsCanvas);
|
||
node._fsOverlay?.remove(); node._fsOverlay = null;
|
||
reattach();
|
||
}
|
||
|
||
// ── floating / dockable editor panel ──
|
||
// Active-context position offset (set by applyDockTransform): dockParent (parent) or dockGraph (self).
|
||
// dockParent is created by applyDockTransform with the right default; fall back to dockGraph if absent.
|
||
function dockPos() {
|
||
return node._dockCtx === "parent" ? (node.properties.dockParent || node.properties.dockGraph) : node.properties.dockGraph;
|
||
}
|
||
// The node the dock is currently anchored to: the node itself, or the SubgraphNode at parent level.
|
||
function dockHostNode() { return node._dockHost || node; }
|
||
// Persist the panel geometry — screen-space rect when unpinned, graph-space size when pinned.
|
||
function saveDockGeom() {
|
||
const fl = node._dockEl; if (!fl) return;
|
||
if (node.properties.dockMin) return; // collapsed: don't persist the header-only size
|
||
// sub-min = transient (hidden / mid-reparent) → bogus, don't persist. (offsetParent is null for the fixed dock.)
|
||
if (fl.offsetWidth < DOCK_MINW || fl.offsetHeight < DOCK_MINH) return;
|
||
if (node.properties.dockPinned) {
|
||
node.properties.dockGraph = node.properties.dockGraph || { x: 0, y: 0 };
|
||
node.properties.dockGraph.w = fl.offsetWidth; node.properties.dockGraph.h = fl.offsetHeight;
|
||
} else {
|
||
const r = fl.getBoundingClientRect();
|
||
node.properties.dockRect = { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
|
||
}
|
||
}
|
||
// Nudge ComfyUI's change tracker (it snapshots on mouseup, which our preventDefault'd drags suppress).
|
||
function flushChange() { try { window.dispatchEvent(new MouseEvent("mouseup")); } catch (e) {} }
|
||
function setPinned(on, pinBtn) {
|
||
const fl = node._dockEl, c = app.canvas; if (!fl || !c) return;
|
||
const rect = c.canvas.getBoundingClientRect(), ds = c.ds;
|
||
if (on) { // return to where it was pinned (the saved dockGraph)
|
||
let g = node.properties.dockGraph;
|
||
if (!g) { // never pinned before — derive from the current float position
|
||
const r = fl.getBoundingClientRect();
|
||
const gx = (r.left - rect.left) / ds.scale - ds.offset[0], gy = (r.top - rect.top) / ds.scale - ds.offset[1];
|
||
g = node.properties.dockGraph = {
|
||
x: gx - (node.pos?.[0] ?? 0), y: gy - (node.pos?.[1] ?? 0),
|
||
w: Math.round(fl.offsetWidth / ds.scale), h: Math.round(fl.offsetHeight / ds.scale),
|
||
};
|
||
}
|
||
node.properties.dockPinned = true;
|
||
fl.style.left = ""; fl.style.top = "";
|
||
fl.style.width = g.w + "px"; fl.style.height = g.h + "px";
|
||
node._dockSig = ""; node._dockHostEl = null; node._dockHostId = null; // force a fresh place + re-parent
|
||
pinnedDocks.add(node); startDockLoop();
|
||
applyDockTransform(node); // place this frame (avoid a flash at the old float spot)
|
||
} else { // freeze current visual rect as a body-fixed screen panel
|
||
const r = fl.getBoundingClientRect();
|
||
node.properties.dockPinned = false;
|
||
pinnedDocks.delete(node);
|
||
node._dockSig = ""; node._dockHostEl = null; node._dockHostId = null;
|
||
document.body.appendChild(fl); // back out of the node element
|
||
fl.style.position = "fixed"; fl.style.transform = ""; fl.style.transformOrigin = ""; fl.style.zIndex = "";
|
||
fl.style.left = Math.round(r.left) + "px"; fl.style.top = Math.round(r.top) + "px";
|
||
fl.style.width = Math.round(r.width) + "px"; fl.style.height = Math.round(r.height) + "px";
|
||
node.properties.dockRect = { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
|
||
}
|
||
if (pinBtn) { pinBtn.classList.toggle("active", on); pinBtn.title = on ? "Unpin from canvas (float in screen)" : "Pin to canvas (move/zoom with the graph)"; }
|
||
c.setDirtyCanvas(true, true); requestAnimationFrame(fitFsCanvas);
|
||
flushChange(); // click's mouseup already fired before this handler — snapshot the new state
|
||
}
|
||
// Resize from any edge/corner; N/W edges shift the anchor. Units: graph px pinned (÷scale), else screen px.
|
||
function startDockResize(e, dir, fl) {
|
||
if (e.button !== 0 || node.flags?.pinned) return; // node pinned → not resizable, like the node
|
||
e.preventDefault(); e.stopPropagation();
|
||
const pinned = !!node.properties.dockPinned;
|
||
const scale = (pinned && app.canvas) ? (app.canvas.ds.scale || 1) : 1;
|
||
const sx = e.clientX, sy = e.clientY, w0 = fl.offsetWidth, h0 = fl.offsetHeight;
|
||
const startPos = dockPos(); // active context: dockGraph (self) or dockParent
|
||
const gx0 = pinned ? startPos.x : (parseFloat(fl.style.left) || 0);
|
||
const gy0 = pinned ? startPos.y : (parseFloat(fl.style.top) || 0);
|
||
const MINW = DOCK_MINW, MINH = DOCK_MINH;
|
||
dragPointer(e, e.currentTarget, (me) => {
|
||
const dx = (me.clientX - sx) / scale, dy = (me.clientY - sy) / scale;
|
||
let w = w0, h = h0, gx = gx0, gy = gy0;
|
||
if (dir.indexOf("e") >= 0) w = w0 + dx;
|
||
if (dir.indexOf("s") >= 0) h = h0 + dy;
|
||
if (dir.indexOf("w") >= 0) { w = w0 - dx; gx = gx0 + dx; }
|
||
if (dir.indexOf("n") >= 0) { h = h0 - dy; gy = gy0 + dy; }
|
||
if (w < MINW) { if (dir.indexOf("w") >= 0) gx -= (MINW - w); w = MINW; }
|
||
if (h < MINH) { if (dir.indexOf("n") >= 0) gy -= (MINH - h); h = MINH; }
|
||
fl.style.width = Math.round(w) + "px"; fl.style.height = Math.round(h) + "px";
|
||
if (pinned) { const p = dockPos(); p.x = gx; p.y = gy; node._dockSig = ""; applyDockTransform(node); }
|
||
else { fl.style.left = Math.round(gx) + "px"; fl.style.top = Math.round(gy) + "px"; }
|
||
fitFsCanvas();
|
||
}, () => { saveDockGeom(); flushChange(); });
|
||
}
|
||
function addDockResizeHandles(fl) {
|
||
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
|
||
const h = document.createElement("div");
|
||
h.className = "ai2go-ideo-rsz " + dir;
|
||
h.addEventListener("pointerdown", (e) => startDockResize(e, dir, fl));
|
||
fl.appendChild(h);
|
||
}
|
||
}
|
||
function undock() {
|
||
if (node._docked) return;
|
||
if (node._fullscreen) exitFs();
|
||
node._docked = true; node.properties.docked = true;
|
||
const fl = document.createElement("div"); fl.className = "ai2go-ideo-dock";
|
||
fl.dataset.captureWheel = "true"; // 2.0: let focused inputs in the dock scroll instead of zooming the graph
|
||
stopProp(fl); // hosted inside the node element — don't let dock interactions drag/zoom the node
|
||
addMiddleClickPan(fl); // middle-click pans the graph from anywhere in the dock (bubbles up from inner elements)
|
||
fl.addEventListener("mousedown", (e) => { if (e.button === 1) e.stopPropagation(); }); // ...but don't let it reach the node behind
|
||
const head = document.createElement("div"); head.className = "ai2go-ideo-dock-head";
|
||
const title = document.createElement("span"); title.textContent = "Ideogram 4 editor"; title.style.flex = "1";
|
||
const minBtn = document.createElement("button"); minBtn.className = "ai2go-ideo-btn";
|
||
stopProp(minBtn);
|
||
const applyMin = (on) => { // collapse the editor to just the header bar
|
||
node.properties.dockMin = !!on;
|
||
fl.classList.toggle("minimized", !!on);
|
||
minBtn.textContent = on ? "▢" : "—";
|
||
minBtn.title = on ? "Restore editor" : "Minimize editor";
|
||
if (!on) requestAnimationFrame(fitFsCanvas); // re-letterbox the canvas after it's visible again
|
||
};
|
||
minBtn.addEventListener("click", () => { applyMin(!node.properties.dockMin); flushChange(); });
|
||
const fsBtn = document.createElement("button"); fsBtn.className = "ai2go-ideo-btn"; fsBtn.textContent = "⛶";
|
||
fsBtn.title = "Open in a larger window (Esc to close)";
|
||
stopProp(fsBtn); fsBtn.addEventListener("click", () => node._fullscreen ? exitFs() : enterFs());
|
||
const pinBtn = document.createElement("button"); pinBtn.className = "ai2go-ideo-btn"; pinBtn.textContent = "📌";
|
||
stopProp(pinBtn); pinBtn.addEventListener("click", () => setPinned(!node.properties.dockPinned, pinBtn));
|
||
// Opt-in: also show this canvas at the parent (subgraph) level (tickDocks hides this button at root).
|
||
const exBtn = document.createElement("button"); exBtn.className = "ai2go-ideo-btn"; exBtn.textContent = "⤴";
|
||
exBtn.style.display = (node.graph && !node.graph.isRootGraph) ? "" : "none";
|
||
const syncExBtn = () => {
|
||
exBtn.classList.toggle("active", !!node.properties.exposeToParent);
|
||
exBtn.title = (node.properties.exposeToParent ? "Hide" : "Show") + " this canvas at the parent (subgraph) level";
|
||
};
|
||
stopProp(exBtn);
|
||
exBtn.addEventListener("click", () => { node.properties.exposeToParent = !node.properties.exposeToParent; syncExBtn(); node._dockSig = ""; wakeDocks(); flushChange(); });
|
||
syncExBtn();
|
||
node._syncExBtn = syncExBtn; node._exBtn = exBtn; // onConfigure refreshes state; tickDocks toggles visibility
|
||
head.append(title, fsBtn, minBtn, exBtn, pinBtn);
|
||
const body = document.createElement("div"); body.className = "ai2go-ideo-dock-body";
|
||
fl.append(head, body);
|
||
addDockResizeHandles(fl);
|
||
document.body.appendChild(fl);
|
||
node._dockEl = fl; node._dockBody = body; node._dockSig = ""; node._dockHostEl = null; node._dockHostId = null; liveDocks.add(node);
|
||
applyDockTheme(node); // match the node's color theme right away
|
||
detachInto(body);
|
||
// initial geometry / mode
|
||
const pinned = !!node.properties.dockPinned;
|
||
if (pinned) {
|
||
const g = node.properties.dockGraph || { x: 0, y: 0, w: 540, h: 470 };
|
||
fl.style.position = "absolute"; fl.style.width = g.w + "px"; fl.style.height = g.h + "px";
|
||
pinnedDocks.add(node); startDockLoop();
|
||
applyDockTransform(node); // parent + place this frame (avoid a flash at 0,0)
|
||
pinBtn.classList.add("active");
|
||
} else {
|
||
const rc = node.properties.dockRect || { x: 90, y: 90, w: 540, h: 470 };
|
||
fl.style.left = Math.max(0, Math.min(rc.x, window.innerWidth - 80)) + "px";
|
||
fl.style.top = Math.max(0, Math.min(rc.y, window.innerHeight - 40)) + "px";
|
||
fl.style.width = rc.w + "px"; fl.style.height = rc.h + "px";
|
||
}
|
||
pinBtn.title = pinned ? "Unpin from canvas (float in screen)" : "Pin to canvas (move/zoom with the graph)";
|
||
applyMin(!!node.properties.dockMin); // restore minimized state
|
||
// drag the panel by its header (graph-space when pinned, screen-space otherwise)
|
||
head.addEventListener("pointerdown", (e) => {
|
||
if (e.target === pinBtn || e.target === minBtn || e.target === fsBtn || e.button !== 0) return;
|
||
if (node.flags?.pinned) return; // node pinned → dock is locked too
|
||
e.preventDefault();
|
||
const sx0 = e.clientX, sy0 = e.clientY;
|
||
const pinned = node.properties.dockPinned;
|
||
const scale = pinned ? (app.canvas.ds.scale || 1) : 1;
|
||
const dp0 = dockPos(); // active context: dockGraph (self) or dockParent
|
||
const gx0 = pinned ? dp0.x : 0, gy0 = pinned ? dp0.y : 0;
|
||
const ox = fl.offsetLeft, oy = fl.offsetTop;
|
||
let snapReady = false;
|
||
dragPointer(e, head, (me) => {
|
||
if (pinned) { // move the panel by writing its transform directly (no full redraw)
|
||
const gx = gx0 + (me.clientX - sx0) / scale, gy = gy0 + (me.clientY - sy0) / scale;
|
||
const h = dockHostNode(); // snap "home": flush under the anchor (node or SubgraphNode)
|
||
const snap = 26 / scale, underY = h.size[1] + 2;
|
||
snapReady = Math.abs(gx) < snap && Math.abs(gy - underY) < snap; // just highlight; snap on release
|
||
fl.classList.toggle("snap-ready", snapReady);
|
||
const p = dockPos(); p.x = gx; p.y = gy; // follow the cursor freely
|
||
applyDockTransform(node);
|
||
} else {
|
||
fl.style.left = Math.max(0, Math.min(ox + me.clientX - sx0, window.innerWidth - 60)) + "px";
|
||
fl.style.top = Math.max(0, Math.min(oy + me.clientY - sy0, window.innerHeight - 30)) + "px";
|
||
}
|
||
}, () => {
|
||
if (snapReady) { // commit the snap: flush under the anchor, matching its width
|
||
const h = dockHostNode(), w = Math.round(h.size[0]), p = dockPos();
|
||
p.x = 0; p.y = h.size[1] + 2;
|
||
if (node.properties.dockGraph.w !== w) { node.properties.dockGraph.w = w; fl.style.width = w + "px"; }
|
||
node._dockSig = ""; applyDockTransform(node);
|
||
}
|
||
fl.classList.remove("snap-ready");
|
||
saveDockGeom(); flushChange();
|
||
});
|
||
});
|
||
// Re-letterbox the canvas and persist geometry on dock resize. The sub-min guard in
|
||
// saveDockGeom drops the bogus transient reads from Vue re-parenting.
|
||
try { node._dockRO = new ResizeObserver(() => { fitFsCanvas(); saveDockGeom(); }); node._dockRO.observe(fl); } catch (e) {}
|
||
requestAnimationFrame(fitFsCanvas); // letterbox the canvas once the dock has its size
|
||
}
|
||
// Pin the editor under the node. `fresh` (new node only) matches the node width; reloads honor the blob.
|
||
function ensureDocked(fresh) {
|
||
if (node._docked) return;
|
||
if (node.properties.dockPinned == null) node.properties.dockPinned = true;
|
||
const honor = !!node.properties.dockGraph && node._dockGeomRestored; // only trust blob-restored geometry
|
||
if (!honor) {
|
||
// old workflow: reuse its saved (aspect-locked) node height so the canvas isn't tiny
|
||
const sh = (!fresh && node._savedSize && node._savedSize[1] > 240) ? Math.round(node._savedSize[1]) : 470;
|
||
node.properties.dockGraph = { x: 0, y: 0, w: Math.round(node.size[0]), h: sh }; // match the node width
|
||
}
|
||
undock();
|
||
if (!honor) requestAnimationFrame(() => { // no reliable geometry — place a default under the node, matching its width
|
||
const g = node.properties.dockGraph;
|
||
g.x = 0; g.y = node.size[1] + 2; g.w = Math.round(node.size[0]);
|
||
if (node._dockEl) node._dockEl.style.width = g.w + "px";
|
||
node._dockSig = ""; applyDockTransform(node); saveDockGeom();
|
||
});
|
||
}
|
||
|
||
// ── geometry helpers ── (logical CSS px = the displayed canvas size)
|
||
function logW() { return canvasEl.offsetWidth || 1; }
|
||
function logH() { return canvasEl.offsetHeight || 1; }
|
||
function toPx(b) {
|
||
const W = logW(), H = logH();
|
||
return { x1: b.x * W, y1: b.y * H, x2: (b.x + b.w) * W, y2: (b.y + b.h) * H };
|
||
}
|
||
function mouseN(e) {
|
||
const r = canvasEl.getBoundingClientRect();
|
||
return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
|
||
}
|
||
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
|
||
// greedy word-wrap to maxW px (ctx.font must be set by caller)
|
||
function wrapLines(text, maxW) {
|
||
const lines = [];
|
||
for (const para of text.split("\n")) {
|
||
let line = "";
|
||
for (const word of para.split(/\s+/)) {
|
||
if (!word) continue;
|
||
const test = line ? line + " " + word : word;
|
||
if (line && ctx.measureText(test).width > maxW) { lines.push(line); line = word; }
|
||
else line = test;
|
||
}
|
||
lines.push(line);
|
||
}
|
||
return lines;
|
||
}
|
||
function normalizeBox(b) {
|
||
// collapse negative size to positive top-left + w/h, clamp into canvas
|
||
let x = b.x, y = b.y, w = b.w, h = b.h;
|
||
if (w < 0) { x += w; w = -w; }
|
||
if (h < 0) { y += h; h = -h; }
|
||
x = clamp01(x); y = clamp01(y);
|
||
w = Math.min(w, 1 - x); h = Math.min(h, 1 - y);
|
||
return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) };
|
||
}
|
||
|
||
// All boxes under the point, top-first to match draw order: the active box is
|
||
// drawn last (on top), then the rest by index low→high (index 0 = front).
|
||
function boxesAt(mN) {
|
||
const baseRx = HANDLE / logW(), baseRy = HANDLE / logH();
|
||
const res = [];
|
||
for (let i = 0; i < node._boxes.length; i++) {
|
||
const b = node._boxes[i];
|
||
if (b.locked || isDisabled(b) || (b.group && node.properties.showGroups !== true)) continue; // locked, disabled, or a hidden group isn't grabbable
|
||
const rx = Math.min(baseRx, b.w / 3), ry = Math.min(baseRy, b.h / 3); // shrink handles on small boxes so a central move zone remains
|
||
const mode = rectHitTestN(mN.x, mN.y, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry);
|
||
if (mode) res.push({ index: i, mode });
|
||
}
|
||
const ai = res.findIndex((c) => c.index === node._activeIdx);
|
||
if (ai > 0) res.unshift(res.splice(ai, 1)[0]);
|
||
return res;
|
||
}
|
||
// Hover / right-click: prefer a resize handle on the active box, else topmost.
|
||
function hitTest(mN) {
|
||
const cands = boxesAt(mN);
|
||
if (!cands.length) return null;
|
||
return cands.find((c) => c.index === node._activeIdx && c.mode !== "move") || cands[0];
|
||
}
|
||
// Tag-chip rects (canvas px), placed to avoid overlapping each other: each
|
||
// box's tag tries top-left, top-right, bottom-right, bottom-left in turn.
|
||
function tagRects() {
|
||
ctx.font = "bold 11px monospace";
|
||
const W = logW(), H = logH(), h = 14;
|
||
const placed = [], rects = [];
|
||
const hits = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
||
for (let i = 0; i < node._boxes.length; i++) {
|
||
const b = node._boxes[i];
|
||
if (b.group) { rects[i] = null; continue; } // groups draw their own name chip; no number badge
|
||
const x1 = b.x * W, y1 = b.y * H, x2 = (b.x + b.w) * W, y2 = (b.y + b.h) * H;
|
||
const tag = String(i + 1).padStart(2, "0");
|
||
const w = ctx.measureText(tag).width + 8;
|
||
// Box too small to hold the badge → place it just outside (above/below/beside) so it doesn't
|
||
// cover the box and the box stays grabbable; otherwise tuck it into a free corner inside.
|
||
const small = (x2 - x1) < w || (y2 - y1) < h;
|
||
const cands = small
|
||
? [[x1, y1 - h - 1], [x1, y2 + 1], [x2 + 1, y1], [x1 - w - 1, y1]]
|
||
: [[x1, y1], [x2 - w, y1], [x2 - w, y2 - h], [x1, y2 - h]];
|
||
let pick = cands[0];
|
||
for (const [cx, cy] of cands) {
|
||
if (cx < 0 || cy < 0 || cx + w > W || cy + h > H) continue; // keep on-canvas
|
||
if (!placed.some((p) => hits({ x: cx, y: cy, w, h }, p))) { pick = [cx, cy]; break; }
|
||
}
|
||
const r = { x: pick[0], y: pick[1], w, h, tag };
|
||
placed.push(r); rects[i] = r;
|
||
}
|
||
return rects;
|
||
}
|
||
function titleAt(mN) {
|
||
const px = mN.x * logW(), py = mN.y * logH();
|
||
const rects = tagRects();
|
||
for (let i = node._boxes.length - 1; i >= 0; i--) {
|
||
if (node._boxes[i].locked || node._boxes[i].group) continue; // locked / group badges aren't interactive
|
||
const r = rects[i];
|
||
if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return i;
|
||
}
|
||
return null;
|
||
}
|
||
// In-box text placement: when auto-place is on, put each box's text in whichever vertical slot overlaps
|
||
// least with the text already placed for other boxes (greedy), so overlapping labels don't stack.
|
||
function textBlocks() {
|
||
if (node.properties.showBoxText === false) return [];
|
||
const { fs, lh } = txtFont();
|
||
ctx.font = fs + "px monospace";
|
||
const W = logW(), H = logH(), pad = 4, tagH = 14, auto = node.properties.textAutoPlace !== false;
|
||
const placed = [], blocks = [];
|
||
const overlap = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)) *
|
||
Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
||
for (let i = 0; i < node._boxes.length; i++) {
|
||
const b = node._boxes[i];
|
||
let body = b.desc || "";
|
||
if (b.type === "text" && b.text) body = `"${b.text}"` + (body ? " — " + body : "");
|
||
if (!body) { blocks[i] = null; continue; }
|
||
const x1 = b.x * W, y1 = b.y * H, x2 = (b.x + b.w) * W, y2 = (b.y + b.h) * H;
|
||
const lines = wrapLines(body, (x2 - x1) - pad * 2); // full width
|
||
let lw = 0; for (const ln of lines) lw = Math.max(lw, ctx.measureText(ln).width);
|
||
const bw = Math.min(x2 - x1, lw + pad * 2), bh = Math.min(y2 - y1, lines.length * lh + 4);
|
||
// candidates = several vertical slots (more when the box is taller); text spans the width, so only Y varies
|
||
const top = y1 + ((y2 - y1) > bh + tagH ? tagH : 0), bottom = y2 - bh;
|
||
const cands = [];
|
||
if (!auto || bottom <= top) cands.push([x1, top]); // off, or fills the box → fixed top
|
||
else {
|
||
const n = Math.min(6, 2 + Math.floor((bottom - top) / (lh * 2)));
|
||
for (let k = 0; k < n; k++) cands.push([x1, top + ((bottom - top) * k) / (n - 1)]);
|
||
}
|
||
let best = cands[0], bestScore = Infinity;
|
||
for (const [cx, cy] of cands) {
|
||
const rect = { x: cx, y: cy, w: bw, h: bh };
|
||
let s = 0; for (const p of placed) s += overlap(rect, p);
|
||
if (s < bestScore) { bestScore = s; best = [cx, cy]; if (s === 0) break; }
|
||
}
|
||
const r = { x: best[0], y: best[1], w: bw, h: bh, lines, lh, fs, pad };
|
||
placed.push(r); blocks[i] = r;
|
||
}
|
||
return blocks;
|
||
}
|
||
// Click selection: active box's resize handle wins (corner resize); then a
|
||
// title-chip click selects that box (drawn to front); Alt-click cycles the
|
||
// overlap stack; else the topmost box.
|
||
function pickForSelection(mN, cycle) {
|
||
const cands = boxesAt(mN);
|
||
if (!cands.length) return null;
|
||
const ah = cands.find((c) => c.index === node._activeIdx && c.mode !== "move");
|
||
if (ah && !cycle) return ah;
|
||
const ti = titleAt(mN);
|
||
if (ti !== null && !cycle) return { index: ti, mode: "move" };
|
||
if (cycle && cands.length > 1) {
|
||
// cycle over a stable order (boxesAt moves the active box to the front, which would otherwise ping-pong 2)
|
||
const ordered = [...cands].sort((a, b) => a.index - b.index);
|
||
const pos = ordered.findIndex((c) => c.index === node._activeIdx);
|
||
return ordered[(pos + 1) % ordered.length];
|
||
}
|
||
return cands.find((c) => c.index === node._activeIdx && c.mode !== "move") || cands[0];
|
||
}
|
||
// normalized variant of rectHitTest with separate x/y radii
|
||
function rectHitTestN(mx, my, x1, y1, x2, y2, rx, ry) {
|
||
const h = (cx, cy) => Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry;
|
||
if (h(x1, y1)) return "resize-tl";
|
||
if (h(x2, y1)) return "resize-tr";
|
||
if (h(x1, y2)) return "resize-bl";
|
||
if (h(x2, y2)) return "resize-br";
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return "resize-t";
|
||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return "resize-b";
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return "resize-l";
|
||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return "resize-r";
|
||
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move";
|
||
return null;
|
||
}
|
||
|
||
function applyDrag(mode, start, dN) {
|
||
const { x, y, w, h } = start;
|
||
const dx = dN.x, dy = dN.y;
|
||
if (mode === "move") {
|
||
return { ...start, x: clamp01(Math.min(x + dx, 1 - w)), y: clamp01(Math.min(y + dy, 1 - h)) };
|
||
}
|
||
if (mode === "draw") {
|
||
const ax = clamp01(x), ay = clamp01(y); // fixed corner (mousedown point)
|
||
const cx = clamp01(x + dx), cy = clamp01(y + dy); // moving corner (cursor), clamped to canvas
|
||
return { ...start, x: Math.min(ax, cx), y: Math.min(ay, cy), w: Math.abs(cx - ax), h: Math.abs(cy - ay) };
|
||
}
|
||
// resize: move only the dragged edges (clamped to the canvas); the others stay anchored.
|
||
const suf = mode.slice(7); // "tl"|"tr"|"bl"|"br"|"t"|"b"|"l"|"r"
|
||
let l = x, t = y, r = x + w, b = y + h;
|
||
if (suf.includes("l")) l = clamp01(l + dx);
|
||
if (suf.includes("r")) r = clamp01(r + dx);
|
||
if (suf.includes("t")) t = clamp01(t + dy);
|
||
if (suf.includes("b")) b = clamp01(b + dy);
|
||
if (r < l) [l, r] = [r, l]; // crossing the opposite edge flips, no anchor drift
|
||
if (b < t) [t, b] = [b, t];
|
||
return { ...start, x: l, y: t, w: r - l, h: b - t };
|
||
}
|
||
|
||
// ── grid / guides ──
|
||
function gridN() { return Math.max(2, Math.min(128, node.properties.gridSize || 10)); }
|
||
function txtFont() { const fs = Math.max(6, Math.min(40, node.properties.textSize || 12)); return { fs, lh: Math.round(fs * 1.2) }; }
|
||
// Whole number of cells per axis so they fill the canvas exactly (no split cells at the edges);
|
||
// near-square (the target size is rounded to fit). Shared by the grid guide and snap-to-grid.
|
||
function gridStep() {
|
||
const W = logW(), H = logH(), target = Math.min(W, H) / gridN();
|
||
const nx = Math.max(1, Math.round(W / target)), ny = Math.max(1, Math.round(H / target));
|
||
return { nx, ny, cw: W / nx, ch: H / ny, sx: 1 / nx, sy: 1 / ny };
|
||
}
|
||
function guideStroke(a) {
|
||
const c = hexRgb(node.properties.guideColor || "#ffffff") || { r: 255, g: 255, b: 255 };
|
||
const op = (node.properties.guideOpacity == null ? 100 : node.properties.guideOpacity) / 100;
|
||
return `rgba(${c.r},${c.g},${c.b},${(a * op).toFixed(3)})`;
|
||
}
|
||
// Snap a box's free edges to the (square) grid when snap-to-grid is on.
|
||
function snapBox(b, mode) {
|
||
if (!node.properties.snap) return b;
|
||
const { sx, sy } = gridStep();
|
||
const sn = (v, s) => Math.round(v / s) * s;
|
||
const { x, y, w, h } = b;
|
||
if (mode === "move") { // snap position, preserve size
|
||
return { ...b, x: clamp01(Math.min(sn(x, sx), 1 - w)), y: clamp01(Math.min(sn(y, sy), 1 - h)) };
|
||
}
|
||
const x2 = sn(x + w, sx), y2 = sn(y + h, sy); // draw/resize: snap the edges
|
||
return normalizeBox({ ...b, x: sn(x, sx), y: sn(y, sy), w: x2 - sn(x, sx), h: y2 - sn(y, sy) });
|
||
}
|
||
|
||
// ── drawing ──
|
||
// Golden spiral: largest φ-rectangle fitting the canvas, subdivided into squares with quarter arcs.
|
||
function goldenSpiral(W, H) {
|
||
const phi = 1.6180339887;
|
||
let w, h;
|
||
if (W >= H) { if (W / H >= phi) { h = H; w = H * phi; } else { w = W; h = W / phi; } }
|
||
else { if (H / W >= phi) { w = W; h = W * phi; } else { h = H; w = H / phi; } }
|
||
let x = (W - w) / 2, y = (H - h) / 2;
|
||
ctx.save();
|
||
ctx.strokeStyle = guideStroke(0.25); ctx.lineWidth = 1;
|
||
ctx.strokeRect(x + 0.5, y + 0.5, w, h);
|
||
ctx.strokeStyle = guideStroke(0.6); ctx.lineWidth = 1.5;
|
||
let phase = w >= h ? 0 : 1;
|
||
for (let i = 0; i < 12 && w > 1 && h > 1; i++, phase = (phase + 1) % 4) {
|
||
const s = Math.min(w, h);
|
||
let cx, cy, a0, a1;
|
||
if (phase === 0) { cx = x + s; cy = y + s; a0 = Math.PI; a1 = Math.PI * 1.5; x += s; w -= s; }
|
||
else if (phase === 1) { cx = x; cy = y + s; a0 = Math.PI * 1.5; a1 = Math.PI * 2; y += s; h -= s; }
|
||
else if (phase === 2) { cx = x + w - s; cy = y; a0 = 0; a1 = Math.PI * 0.5; w -= s; }
|
||
else { cx = x + w; cy = y + h - s; a0 = Math.PI * 0.5; a1 = Math.PI; h -= s; }
|
||
ctx.beginPath(); ctx.arc(cx, cy, s, a0, a1); ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
// Composition guide overlay (rule of thirds / grid / golden ratio / spiral), drawn on the bg.
|
||
function drawGuide(W, H) {
|
||
const kind = node.properties.guide;
|
||
if (!kind || kind === "none") return;
|
||
if (kind === "spiral") { goldenSpiral(W, H); return; }
|
||
ctx.save();
|
||
ctx.lineWidth = 1;
|
||
if (kind === "grid") { // whole cells across both axes (no split cells at edges)
|
||
ctx.strokeStyle = guideStroke(0.24);
|
||
const { nx, ny, cw, ch } = gridStep();
|
||
for (let i = 1; i < nx; i++) { const X = Math.round(i * cw) + 0.5; ctx.beginPath(); ctx.moveTo(X, 0); ctx.lineTo(X, H); ctx.stroke(); }
|
||
for (let j = 1; j < ny; j++) { const Y = Math.round(j * ch) + 0.5; ctx.beginPath(); ctx.moveTo(0, Y); ctx.lineTo(W, Y); ctx.stroke(); }
|
||
ctx.restore(); return;
|
||
}
|
||
const v = [], h = []; // thirds / golden: per-axis fractions (correct at any aspect)
|
||
if (kind === "thirds") { v.push(1 / 3, 2 / 3); h.push(1 / 3, 2 / 3); }
|
||
else if (kind === "golden") { const g = 1 / 1.6180339887; v.push(1 - g, g); h.push(1 - g, g); }
|
||
ctx.strokeStyle = guideStroke(kind === "golden" ? 0.45 : 0.28);
|
||
for (const fx of v) { const px = Math.round(fx * W) + 0.5; ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke(); }
|
||
for (const fy of h) { const py = Math.round(fy * H) + 0.5; ctx.beginPath(); ctx.moveTo(0, py); ctx.lineTo(W, py); ctx.stroke(); }
|
||
ctx.restore();
|
||
}
|
||
let _rafPending = false;
|
||
function drawCanvas() {
|
||
if (_rafPending) return;
|
||
_rafPending = true;
|
||
requestAnimationFrame(() => {
|
||
_rafPending = false;
|
||
_draw();
|
||
});
|
||
}
|
||
function _draw() {
|
||
// Size the backing store to display × DPR and draw in logical px (crisp text/lines).
|
||
const W = logW(), H = logH(), d = window.devicePixelRatio || 1;
|
||
const bw = Math.round(W * d), bh = Math.round(H * d);
|
||
if (canvasEl.width !== bw || canvasEl.height !== bh) { canvasEl.width = bw; canvasEl.height = bh; }
|
||
ctx.setTransform(d, 0, 0, d, 0, 0);
|
||
ctx.clearRect(0, 0, W, H);
|
||
let bri = bgBrightnessWidget ? bgBrightnessWidget.value : 25;
|
||
if (typeof bri !== "number" || isNaN(bri)) bri = 25; // guard against unset widget value
|
||
if (node._bgImg) { // reference image, dimmed by brightness
|
||
ctx.drawImage(node._bgImg, 0, 0, W, H);
|
||
const dim = 1 - bri / 100;
|
||
if (dim > 0) { ctx.fillStyle = `rgba(0,0,0,${dim})`; ctx.fillRect(0, 0, W, H); }
|
||
} else { // blank canvas grey from brightness
|
||
const g = Math.round(bri / 100 * 128);
|
||
ctx.fillStyle = `rgb(${g},${g},${g})`; ctx.fillRect(0, 0, W, H);
|
||
}
|
||
drawGuide(W, H); // composition guide overlay
|
||
if (node._hideBoxes) return; // H: temporary background-only view
|
||
// selection highlight only when the editor is focused or the node is selected
|
||
const showSel = node._focused || node._selected;
|
||
const aIdx = showSel ? node._activeIdx : -1;
|
||
const selSet = new Set(showSel ? node._selection : []);
|
||
if (aIdx >= 0) selSet.add(aIdx);
|
||
// index 0 = front (drawn last); selected drawn above non-selected, active last of all
|
||
const nonSel = node._boxes.map((_, i) => i).filter((i) => !selSet.has(i)).reverse();
|
||
const selOthers = node._boxes.map((_, i) => i).filter((i) => selSet.has(i) && i !== aIdx).reverse();
|
||
const order = [...nonSel, ...selOthers];
|
||
if (aIdx >= 0 && aIdx < node._boxes.length) order.push(aIdx);
|
||
const tagR = tagRects(); // collision-avoided tag positions
|
||
const textB = textBlocks(); // collision-avoided in-box text positions
|
||
for (const i of order) {
|
||
const b = node._boxes[i], active = i === aIdx, selected = selSet.has(i) && !b.locked; // locked never shows as selected
|
||
if (isDisabled(b)) continue; // disabled (or under a disabled group) → not drawn
|
||
const pal = (b.palette || []).filter(Boolean);
|
||
const col = pal.length ? pal[0] : "#8c8c8c"; // box color = first palette color, else neutral grey
|
||
const { x1, y1, x2, y2 } = toPx(b);
|
||
const w = x2 - x1, h = y2 - y1;
|
||
const hovered = (i === node._hoverBox && !b.locked) || selected; // locked boxes don't hover; selected stay highlighted
|
||
if (b.group) { // group container: dashed frame + name; no palette strip / desc / number
|
||
if (node.properties.showGroups !== true) continue; // groups hidden by default on the canvas (still shown in the Overview)
|
||
const gcol = pal.length ? pal[0] : "#46b4e6";
|
||
if (selected) { ctx.fillStyle = "rgba(26,26,26,0.55)"; ctx.fillRect(x1, y1, w, h); }
|
||
ctx.save();
|
||
ctx.setLineDash(b.locked ? [3, 3] : [8, 5]); // locked group → tight dash (frozen)
|
||
ctx.strokeStyle = gcol; ctx.lineWidth = selected ? 2 : (hovered ? 1.5 : 1);
|
||
ctx.strokeRect(x1 + 1, y1 + 1, w - 2, h - 2);
|
||
ctx.setLineDash([]);
|
||
ctx.font = "bold 11px sans-serif";
|
||
const nm = "📁 " + (b.name || "Group");
|
||
const tw = Math.min(ctx.measureText(nm).width + 8, Math.max(8, w));
|
||
ctx.fillStyle = gcol; ctx.fillRect(x1, y1, tw, 14);
|
||
ctx.save(); ctx.beginPath(); ctx.rect(x1, y1, w, 14); ctx.clip();
|
||
ctx.fillStyle = textOn(gcol); ctx.fillText(nm, x1 + 4, y1 + 11);
|
||
ctx.restore();
|
||
if (b.locked) { ctx.font = "10px sans-serif"; ctx.fillStyle = "#ddd"; ctx.fillText("🔒", Math.min(x1 + tw + 2, x2 - 14), y1 + 11); }
|
||
if (selected) { // orange selection ring (solid = active, dashed = also-selected)
|
||
const olw = active ? 2 : 1;
|
||
ctx.strokeStyle = "#ff8c00"; ctx.lineWidth = olw;
|
||
if (!active) ctx.setLineDash([5, 3]);
|
||
ctx.strokeRect(x1 + olw / 2, y1 + olw / 2, w - olw, h - olw);
|
||
ctx.setLineDash([]);
|
||
}
|
||
ctx.restore();
|
||
continue;
|
||
}
|
||
if (selected) { // opaque backing so contents read clearly over boxes behind
|
||
ctx.fillStyle = "rgba(26,26,26,0.88)";
|
||
ctx.fillRect(x1, y1, w, h);
|
||
}
|
||
const baseA = (node.properties.boxOpacity == null ? 14 : node.properties.boxOpacity) / 100;
|
||
const fillA = Math.min(1, hovered ? baseA + 0.1 : baseA); // box-color tint at the chosen opacity
|
||
ctx.fillStyle = col + Math.round(fillA * 255).toString(16).padStart(2, "0");
|
||
ctx.fillRect(x1, y1, w, h);
|
||
if (b.locked) ctx.setLineDash([3, 3]); // locked: frozen on the canvas
|
||
else if (b.nobbox) ctx.setLineDash([6, 4]); // unplaced (no bbox in source)
|
||
const lw = selected ? 2 : (hovered ? 1.5 : 1);
|
||
ctx.strokeStyle = col; ctx.lineWidth = lw;
|
||
ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw); // inside the box so strip/badge align at y1
|
||
ctx.setLineDash([]);
|
||
if (pal.length) { // palette shown as a strip along the top edge
|
||
const sw = w / pal.length, n = pal.length, sh = 7;
|
||
for (let p = 0; p < n; p++) {
|
||
const sx = x1 + Math.round(p * sw);
|
||
ctx.fillStyle = pal[p];
|
||
ctx.fillRect(sx, y1, x1 + Math.round((p + 1) * sw) - sx, sh);
|
||
}
|
||
}
|
||
// in-box content (clipped to the box): prompt text (at its de-conflicted corner) + lock badge
|
||
ctx.save();
|
||
ctx.beginPath(); ctx.rect(x1, y1, w, h); ctx.clip();
|
||
const tb = textB[i];
|
||
if (tb) {
|
||
const stroke = node.properties.textStroke !== false;
|
||
ctx.font = tb.fs + "px monospace";
|
||
if (stroke) { ctx.lineWidth = 3; ctx.lineJoin = "round"; ctx.strokeStyle = "rgba(0,0,0,0.85)"; } // dark halo
|
||
ctx.fillStyle = readableText(col); // box color, lightened if too dark
|
||
let ty = tb.y + tb.fs;
|
||
for (const line of tb.lines) {
|
||
if (stroke) ctx.strokeText(line, tb.x + tb.pad, ty);
|
||
ctx.fillText(line, tb.x + tb.pad, ty);
|
||
ty += tb.lh;
|
||
}
|
||
}
|
||
ctx.restore();
|
||
// tag chip on top, unclipped (sits outside boxes too small to hold it — its collision-avoided position)
|
||
const tr = tagR[i];
|
||
ctx.font = "bold 11px monospace";
|
||
ctx.fillStyle = col; // tag chip = box color
|
||
ctx.fillRect(tr.x, tr.y, tr.w, 14);
|
||
if (i === node._hoverTitle && !b.locked) { // hover highlight (never on locked)
|
||
ctx.fillStyle = "rgba(255,255,255,0.25)"; ctx.fillRect(tr.x, tr.y, tr.w, 14);
|
||
ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13);
|
||
}
|
||
ctx.fillStyle = textOn(col);
|
||
ctx.fillText(tr.tag, tr.x + 4, tr.y + 11);
|
||
if (b.locked) { ctx.font = "11px sans-serif"; ctx.fillStyle = "#ddd"; ctx.fillText("🔒", tr.x + tr.w + 2, tr.y + 11); } // lock symbol next to the badge
|
||
if (selected) { // orange selection ring on top (above strip/tag): solid = primary, dashed = others
|
||
const olw = active ? 2 : 1;
|
||
ctx.strokeStyle = "#ff8c00"; ctx.lineWidth = olw;
|
||
if (!active) ctx.setLineDash([5, 3]);
|
||
ctx.strokeRect(x1 + olw / 2, y1 + olw / 2, w - olw, h - olw);
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
const apIdx = node._altPreview ?? -1; // Alt-held: ring the box an alt-click would select next
|
||
if (apIdx >= 0 && apIdx < node._boxes.length) {
|
||
const { x1, y1, x2, y2 } = toPx(node._boxes[apIdx]);
|
||
ctx.strokeStyle = "#46b4e6"; ctx.lineWidth = 2; ctx.setLineDash([5, 3]);
|
||
ctx.strokeRect(x1 + 1, y1 + 1, (x2 - x1) - 2, (y2 - y1) - 2);
|
||
ctx.setLineDash([]);
|
||
}
|
||
if (node._marquee && node._marqueeActive) { // rubber-band selection rectangle
|
||
const r = marqueeRect();
|
||
const mx = r.x * W, my = r.y * H, mw = r.w * W, mh = r.h * H;
|
||
ctx.fillStyle = "rgba(70,180,230,0.12)";
|
||
ctx.fillRect(mx, my, mw, mh);
|
||
ctx.strokeStyle = "#46b4e6"; ctx.lineWidth = 1; ctx.setLineDash([4, 3]);
|
||
ctx.strokeRect(mx + 0.5, my + 0.5, mw - 1, mh - 1);
|
||
ctx.setLineDash([]);
|
||
}
|
||
}
|
||
|
||
// ── serialization ──
|
||
// Wired AND the source node isn't muted(2)/bypassed(4) — those keep the link but emit nothing.
|
||
const importConnected = () => {
|
||
const link = node.graph?.links?.[node.inputs?.find((i) => i.name === "import_json")?.link];
|
||
return !!link && ![2, 4].includes(node.graph.getNodeById(link.origin_id)?.mode);
|
||
};
|
||
// Stamp a stable id on any box missing one — identity for the Object Explorer and (future)
|
||
// parenting. Editor-only: Python builds caption elements from known keys, so id never exports.
|
||
function ensureIds() {
|
||
for (const b of node._boxes) if (b && !b.id) b.id = makeUUID();
|
||
}
|
||
// ── region hierarchy (parenting) — editor-only; `parent` holds another box's id (or null = root).
|
||
function boxById(id) { return id == null ? null : node._boxes.find((b) => b.id === id); }
|
||
// True if `ancId` is an ancestor of `id` (walks the parent chain; guarded against malformed cycles).
|
||
function isAncestor(ancId, id) {
|
||
let cur = boxById(id), guard = 0;
|
||
while (cur && cur.parent != null && guard++ < 1000) {
|
||
if (cur.parent === ancId) return true;
|
||
cur = boxById(cur.parent);
|
||
}
|
||
return false;
|
||
}
|
||
// A box is effectively disabled if it OR any ancestor has `disabled` (disabling a group disables all
|
||
// its members). Effectively-disabled boxes are hidden on the canvas AND excluded from the prompt.
|
||
function isDisabled(b) {
|
||
let cur = b, guard = 0;
|
||
while (cur && guard++ < 1000) { if (cur.disabled) return true; cur = cur.parent != null ? boxById(cur.parent) : null; }
|
||
return false;
|
||
}
|
||
// All descendant ids of `id` (excludes id itself).
|
||
function descendantIds(id) {
|
||
const kids = new Map();
|
||
for (const b of node._boxes) if (b.parent != null) { if (!kids.has(b.parent)) kids.set(b.parent, []); kids.get(b.parent).push(b.id); }
|
||
const out = new Set(), stack = [...(kids.get(id) || [])];
|
||
while (stack.length) { const cid = stack.pop(); if (out.has(cid)) continue; out.add(cid); for (const k of (kids.get(cid) || [])) stack.push(k); }
|
||
return out;
|
||
}
|
||
// Set (parentId=null clears) a box's parent, rejecting self / missing target / cycles. Returns true if applied.
|
||
function setParent(childId, parentId) {
|
||
if (childId == null || childId === parentId) return false;
|
||
if (parentId != null && (!boxById(parentId) || isAncestor(childId, parentId))) return false;
|
||
const c = boxById(childId); if (!c) return false;
|
||
const np = parentId == null ? null : parentId;
|
||
if ((c.parent ?? null) === np) return false; // already this parent — no-op, don't churn a commit
|
||
c.parent = np;
|
||
return true;
|
||
}
|
||
// Keep parent refs sane after delete/import/restore (a hand-edited or imported save may carry
|
||
// garbage): drop refs to a missing box, null self-parents, and break cycles — otherwise the
|
||
// affected boxes never key to a root in renderExplorer and silently vanish from the tree.
|
||
function ensureParentsValid() {
|
||
const ids = new Set(node._boxes.map((b) => b.id));
|
||
for (const b of node._boxes) if (b.parent != null && (!ids.has(b.parent) || b.parent === b.id)) b.parent = null;
|
||
for (const b of node._boxes) { // cut any cycle by nulling the back-edge
|
||
const seen = new Set([b.id]);
|
||
let cur = b, guard = 0;
|
||
while (cur && cur.parent != null && guard++ < 10000) {
|
||
if (seen.has(cur.parent)) { cur.parent = null; break; }
|
||
seen.add(cur.parent);
|
||
cur = boxById(cur.parent);
|
||
}
|
||
}
|
||
}
|
||
function serialize() { // saved/restored value: clean boxes
|
||
ensureIds();
|
||
ensureParentsValid();
|
||
if (elementsWidget) elementsWidget.value = node._boxes.length ? JSON.stringify(node._boxes) : "";
|
||
if (stylePaletteWidget) stylePaletteWidget.value = node._stylePalette.length ? JSON.stringify(node._stylePalette) : "";
|
||
}
|
||
// Queue-time value (not the saved value): when a wired import should drive the output — "always"
|
||
// mode, or empty in "when empty" mode — return a unique marker so ComfyUI can't cache-skip the
|
||
// node. It then re-executes and pushes the import back via ui, refreshing the editor. The server
|
||
// treats a non-list elements_data as empty (and ignores it entirely in "always" mode), so the
|
||
// nonce never affects the output.
|
||
if (elementsWidget) {
|
||
elementsWidget.serializeValue = () => {
|
||
const always = findW("import_mode")?.value === "always";
|
||
if (importConnected() && (always || !node._boxes.length)) {
|
||
return JSON.stringify({ _refresh: (node._serialSeq = (node._serialSeq || 0) + 1) });
|
||
}
|
||
return node._boxes.length ? JSON.stringify(node._boxes) : "";
|
||
};
|
||
}
|
||
|
||
function commit() { serialize(); renderPanel(); renderExplorer(); drawCanvas(); updateTokens(); flushChange(); } // flush so canvas edits persist without a defocus
|
||
// Live text edit: persist + repaint + token count, without rebuilding the panel.
|
||
function touch() { serialize(); drawCanvas(); updateTokens(); }
|
||
|
||
function removeBox(i) {
|
||
const rem = node._boxes[i];
|
||
if (rem) for (const b of node._boxes) if (b.parent === rem.id) b.parent = (rem.parent != null ? rem.parent : null); // promote children to grandparent
|
||
node._boxes.splice(i, 1);
|
||
node._selection = new Set();
|
||
if (node._boxes.length === 0) node._activeIdx = -1;
|
||
else if (i <= node._activeIdx) node._activeIdx = Math.max(0, node._activeIdx - 1);
|
||
if (node._activeIdx >= 0) node._selection.add(node._activeIdx);
|
||
}
|
||
// Delete every selected box (or the active one) and reset the selection.
|
||
function removeSelected() {
|
||
const idxs = [...node._selection].sort((a, b) => b - a).filter((i) => !node._boxes[i]?.locked); // keep locked
|
||
if (!idxs.length && node._activeIdx >= 0 && !node._boxes[node._activeIdx]?.locked) idxs.push(node._activeIdx);
|
||
if (!idxs.length) return;
|
||
// Promote orphans to the nearest surviving ancestor (matching the per-row ✕ delete), skipping over
|
||
// any co-deleted parents, before removing the boxes.
|
||
const removed = new Set(idxs.map((i) => node._boxes[i].id));
|
||
const survivorParent = (pid) => { let g = 0; while (pid != null && removed.has(pid) && g++ < 1000) pid = boxById(pid)?.parent ?? null; return pid; };
|
||
for (const b of node._boxes) if (b.parent != null && removed.has(b.parent)) b.parent = survivorParent(b.parent);
|
||
for (const i of idxs) node._boxes.splice(i, 1);
|
||
node._selection = new Set();
|
||
node._activeIdx = node._boxes.length ? Math.min(idxs[idxs.length - 1], node._boxes.length - 1) : -1;
|
||
if (node._activeIdx >= 0) node._selection.add(node._activeIdx);
|
||
}
|
||
// Replace the selection with a single box (used by clicks / programmatic selects).
|
||
function selectOnly(idx) {
|
||
node._activeIdx = idx;
|
||
node._selection = idx >= 0 ? new Set([idx]) : new Set();
|
||
}
|
||
// Group the current selection under a new named "group" box. Groups are EDITOR-ONLY (never exported);
|
||
// they parent the selection so moving the group moves the whole set. Children keep their own internal
|
||
// hierarchy — only the top-level selected boxes are reparented to the group. The group's bbox is the
|
||
// selection's union plus a small margin (so its dashed frame is grabbable on the canvas to move it).
|
||
function createGroupFromSelection() {
|
||
const idxs = [...node._selection].filter((i) => node._boxes[i]);
|
||
if (!idxs.length) return;
|
||
const name = (window.prompt("Group name:", "Group") || "").trim() || "Group";
|
||
let minx = 1, miny = 1, maxx = 0, maxy = 0, any = false;
|
||
for (const i of idxs) { const b = node._boxes[i]; if (b.nobbox) continue; minx = Math.min(minx, b.x); miny = Math.min(miny, b.y); maxx = Math.max(maxx, b.x + b.w); maxy = Math.max(maxy, b.y + b.h); any = true; }
|
||
if (!any) { minx = 0.05; miny = 0.05; maxx = 0.45; maxy = 0.35; }
|
||
const pad = 0.015;
|
||
minx = clamp01(minx - pad); miny = clamp01(miny - pad); maxx = clamp01(maxx + pad); maxy = clamp01(maxy + pad);
|
||
const parents = new Set(idxs.map((i) => node._boxes[i].parent ?? null));
|
||
const groupParent = parents.size === 1 ? [...parents][0] : null; // sits where the selection lived, else root
|
||
const group = { id: makeUUID(), group: true, name, type: "obj", text: "", desc: "", palette: [],
|
||
x: minx, y: miny, w: Math.max(0, maxx - minx), h: Math.max(0, maxy - miny), parent: groupParent };
|
||
node._boxes.push(group); // appended → drawn behind its children (a container)
|
||
const selIds = new Set(idxs.map((i) => node._boxes[i].id));
|
||
for (const i of idxs) { const b = node._boxes[i]; if (!(b.parent != null && selIds.has(b.parent))) b.parent = group.id; } // reparent only the top-level of the selection
|
||
selectOnly(node._boxes.indexOf(group));
|
||
commit(); fitCanvas();
|
||
}
|
||
// Duplicate a group and its whole subtree (fresh ids, parents remapped within the copy, shifted so it's visible).
|
||
function duplicateGroup(groupId) {
|
||
const g = boxById(groupId); if (!g || !g.group) return;
|
||
const ids = [groupId, ...descendantIds(groupId)];
|
||
const idMap = new Map(), clones = [];
|
||
for (const oldId of ids) {
|
||
const src = boxById(oldId); if (!src) continue;
|
||
const c = JSON.parse(JSON.stringify(src)); const nid = makeUUID();
|
||
idMap.set(oldId, nid); c.id = nid; delete c.locked; // the copy is editable
|
||
clones.push(c);
|
||
}
|
||
for (const c of clones) c.parent = (c.parent != null && idMap.has(c.parent)) ? idMap.get(c.parent) : (g.parent ?? null);
|
||
// Shift the whole copy by ONE uniform delta so its internal layout is preserved exactly. Bound the
|
||
// delta by the copy's ACTUAL extent (a member may protrude past the frame), and fall back to a
|
||
// negative shift when there's no room on the positive side. No per-clone clamp (it would distort).
|
||
const placed = clones.filter((c) => !c.nobbox);
|
||
if (placed.length) {
|
||
const maxR = Math.max(...placed.map((c) => c.x + c.w)), maxB = Math.max(...placed.map((c) => c.y + c.h));
|
||
const minL = Math.min(...placed.map((c) => c.x)), minT = Math.min(...placed.map((c) => c.y));
|
||
let dx = Math.min(0.03, Math.max(0, 1 - maxR)); if (dx === 0) dx = -Math.min(0.03, minL);
|
||
let dy = Math.min(0.03, Math.max(0, 1 - maxB)); if (dy === 0) dy = -Math.min(0.03, minT);
|
||
for (const c of placed) { c.x += dx; c.y += dy; }
|
||
}
|
||
node._boxes.push(...clones);
|
||
selectOnly(node._boxes.findIndex((x) => x.id === idMap.get(groupId)));
|
||
commit(); fitCanvas();
|
||
}
|
||
// Mirror a group's members horizontally ("h") or vertically ("v") about the group's frame (frame unchanged).
|
||
function mirrorGroup(groupId, axis) {
|
||
const g = boxById(groupId); if (!g || !g.group || (axis !== "h" && axis !== "v")) return;
|
||
// Collect members reachable WITHOUT crossing a locked box, so a nested sub-group frame and its
|
||
// children always mirror together (or are skipped together) — frames keep enclosing their kids.
|
||
const kmap = new Map();
|
||
for (const bb of node._boxes) if (bb.parent != null) { if (!kmap.has(bb.parent)) kmap.set(bb.parent, []); kmap.get(bb.parent).push(bb); }
|
||
const targets = [];
|
||
const collect = (pid) => { for (const ch of (kmap.get(pid) || [])) { if (ch.locked) continue; if (!ch.nobbox) targets.push(ch); collect(ch.id); } };
|
||
collect(groupId);
|
||
for (const c of targets) { // clamp the whole interval (keeps size, stays on-canvas)
|
||
if (axis === "h") c.x = Math.max(0, Math.min(2 * g.x + g.w - c.x - c.w, 1 - c.w));
|
||
else c.y = Math.max(0, Math.min(2 * g.y + g.h - c.y - c.h, 1 - c.h));
|
||
}
|
||
commit();
|
||
}
|
||
// Right-click menu for the Overview (one item for now: group the selection).
|
||
function closeOverviewMenu() {
|
||
if (node._ovMenu) { node._ovMenu.remove(); node._ovMenu = null; }
|
||
node._ovDismiss?.disarm(); node._ovDismiss = null;
|
||
}
|
||
function openOverviewMenu(clientX, clientY, ctxBox) {
|
||
closeOverviewMenu();
|
||
const menu = document.createElement("div");
|
||
menu.className = "ai2go-ideo-menu";
|
||
const addItem = (label, enabled, onClick) => {
|
||
const it = document.createElement("div");
|
||
it.className = "ai2go-ideo-trow";
|
||
it.style.cssText = "padding:5px 10px;cursor:" + (enabled ? "pointer" : "default") + (enabled ? "" : ";opacity:0.5");
|
||
it.textContent = label;
|
||
if (enabled) it.addEventListener("click", () => { closeOverviewMenu(); onClick(); });
|
||
menu.appendChild(it);
|
||
};
|
||
// Act on the right-clicked row's box — or the whole selection if that row is part of it; else the selection.
|
||
const selN = node._selection.size;
|
||
let targets = ctxBox
|
||
? (node._selection.has(node._boxes.indexOf(ctxBox)) ? [...node._selection].map((i) => node._boxes[i]) : [ctxBox])
|
||
: [...node._selection].map((i) => node._boxes[i]);
|
||
targets = targets.filter(Boolean);
|
||
const nested = targets.filter((b) => b.parent != null); // only boxes that currently have a parent can move to root
|
||
addItem(selN ? `Create group from selection (${selN})` : "Select regions first to group them",
|
||
selN > 0, () => createGroupFromSelection());
|
||
addItem(nested.length ? `Move to root${nested.length > 1 ? " (" + nested.length + ")" : ""}` : "Move to root (nothing nested)",
|
||
nested.length > 0, () => {
|
||
for (const b of nested) setParent(b.id, null);
|
||
node._selection = new Set(nested.map((b) => node._boxes.indexOf(b)).filter((i) => i >= 0));
|
||
if (nested.length) node._activeIdx = node._boxes.indexOf(nested[nested.length - 1]);
|
||
commit();
|
||
});
|
||
document.body.appendChild(menu);
|
||
node._ovMenu = menu;
|
||
const r = menu.getBoundingClientRect();
|
||
menu.style.left = Math.max(4, Math.min(clientX, window.innerWidth - r.width - 4)) + "px";
|
||
menu.style.top = Math.max(4, Math.min(clientY, window.innerHeight - r.height - 4)) + "px";
|
||
node._ovDismiss = outsideDismiss(menu, () => closeOverviewMenu());
|
||
node._ovDismiss.arm();
|
||
}
|
||
|
||
// ── pointer interaction ──
|
||
canvasEl.addEventListener("pointerdown", (e) => {
|
||
if (node._placing) { // drop the duplicate being placed
|
||
if (e.button === 0) { placeFollower(mouseN(e)); finishPlacing(); }
|
||
else cancelPlacing();
|
||
e.preventDefault(); e.stopPropagation();
|
||
return;
|
||
}
|
||
if (e.button !== 0) return;
|
||
if (node._hideBoxes) return; // view-only while boxes are hidden (H)
|
||
canvasEl.focus(); // so Delete/Backspace targets this editor
|
||
// capture the pointer so move/up keep coming even when the cursor leaves the node
|
||
try { canvasEl.setPointerCapture(e.pointerId); } catch (e2) {}
|
||
node._hoverTitle = null; node._hoverBox = null; // clear hover highlight while interacting
|
||
const mN = mouseN(e);
|
||
// Ctrl/Cmd forces drawing a new box even when starting over an existing one.
|
||
const hit = (e.ctrlKey || e.metaKey) ? null : pickForSelection(mN, e.altKey);
|
||
// Touch double-tap opens the inline editor (dblclick may not fire on touch).
|
||
if (e.pointerType && e.pointerType !== "mouse") {
|
||
const last = node._lastTap, now = e.timeStamp;
|
||
if (hit && last && now - last.t < 350 && Math.abs(mN.x - last.x) < 0.03 && Math.abs(mN.y - last.y) < 0.03) {
|
||
node._lastTap = null; openInlineEditor(hit.index);
|
||
e.preventDefault(); e.stopPropagation(); return;
|
||
}
|
||
node._lastTap = { t: now, x: mN.x, y: mN.y };
|
||
}
|
||
node._pendingCollapse = -1;
|
||
node._groupStart = null;
|
||
if (e.shiftKey) { // shift-drag = marquee select; shift-click = toggle
|
||
startMarquee(mN, hit ? hit.index : -1);
|
||
e.preventDefault(); e.stopPropagation();
|
||
return;
|
||
}
|
||
if (hit) {
|
||
if (!node._selection.has(hit.index)) node._selection = new Set([hit.index]); // outside selection → pick it
|
||
else if (node._selection.size > 1) node._pendingCollapse = hit.index; // click (no drag) collapses
|
||
node._activeIdx = hit.index;
|
||
node._dragMode = hit.mode;
|
||
node._boxAtStart = { ...node._boxes[hit.index] };
|
||
// Build the drag group. A MOVE drags each selected box's whole subtree (move a parent/group → its
|
||
// children follow). Resizing a GROUP scales the group's subtree with it. Both skip locked
|
||
// sub-branches (and resize skips unplaced children). Resizing a normal region scales only the
|
||
// explicitly-selected boxes (its children don't scale).
|
||
const groupIdx = new Set(node._selection);
|
||
const kmap = new Map();
|
||
for (const bb of node._boxes) if (bb.parent != null) { if (!kmap.has(bb.parent)) kmap.set(bb.parent, []); kmap.get(bb.parent).push(bb); }
|
||
const addSub = (pid, skipUnplaced) => { for (const ch of (kmap.get(pid) || [])) { if (ch.locked || (skipUnplaced && ch.nobbox)) continue; groupIdx.add(node._boxes.indexOf(ch)); addSub(ch.id, skipUnplaced); } };
|
||
if (hit.mode === "move") {
|
||
for (const si of [...node._selection]) addSub(node._boxes[si].id, false);
|
||
} else if (hit.mode.startsWith("resize") && node._boxes[hit.index].group) {
|
||
addSub(node._boxes[hit.index].id, true); // resize a group → scale its placed children with it
|
||
}
|
||
if (groupIdx.size > 1) { // snapshot the whole group for group move/resize
|
||
node._groupStart = {};
|
||
for (const i of groupIdx) node._groupStart[i] = { ...node._boxes[i] };
|
||
}
|
||
} else {
|
||
node._dragMode = "draw";
|
||
const nb = { x: mN.x, y: mN.y, w: 0, h: 0, type: "obj", text: "", desc: "", palette: [] };
|
||
node._boxes.push(nb);
|
||
node._activeIdx = node._boxes.length - 1;
|
||
node._selection = new Set([node._activeIdx]);
|
||
node._boxAtStart = { ...nb };
|
||
}
|
||
node._drawing = true;
|
||
node._dragStartN = mN;
|
||
canvasEl.addEventListener("pointermove", onMove);
|
||
canvasEl.addEventListener("pointerup", onUp);
|
||
canvasEl.addEventListener("pointercancel", onUp); // touch can cancel instead of up
|
||
e.preventDefault(); e.stopPropagation();
|
||
drawCanvas(); // panel rebuild/resize deferred to onUp so the canvas doesn't shift mid-drag
|
||
});
|
||
|
||
// Hover targets: with Alt, preview what an alt-click would select next (the box under the current one).
|
||
function hoverTargets(mN, alt, force) {
|
||
if (alt && !force) { const pick = pickForSelection(mN, true); return { ti: null, hb: null, ap: pick ? pick.index : -1, hit: null }; }
|
||
const ti = force ? null : titleAt(mN);
|
||
const hit = force ? null : hitTest(mN);
|
||
return { ti, hb: ti != null ? ti : (hit ? hit.index : null), ap: -1, hit };
|
||
}
|
||
function applyHover(t) {
|
||
if (t.ti !== node._hoverTitle || t.hb !== node._hoverBox || t.ap !== (node._altPreview ?? -1)) {
|
||
node._hoverTitle = t.ti; node._hoverBox = t.hb; node._altPreview = t.ap; drawCanvas();
|
||
}
|
||
}
|
||
canvasEl.addEventListener("pointermove", (e) => {
|
||
node._lastMouseN = mouseN(e); // track cursor for paste-under-cursor
|
||
if (node._placing) { placeFollower(node._lastMouseN); return; }
|
||
if (node._drawing || node._marquee || node._hideBoxes) return;
|
||
const mN = node._lastMouseN;
|
||
const force = e.ctrlKey || e.metaKey, alt = e.altKey; // Ctrl/Cmd = force-draw, Alt = cycle preview
|
||
const t = hoverTargets(mN, alt, force);
|
||
applyHover(t);
|
||
canvasEl.style.cursor = (alt && !force) ? (t.ap >= 0 ? "pointer" : "crosshair")
|
||
: (t.ti != null ? "pointer" : (t.hit ? (cursorForBboxMode(t.hit.mode) || "crosshair") : "crosshair"));
|
||
});
|
||
// Alt pressed/released without moving the mouse — refresh the preview from the last cursor position.
|
||
node._altRefresh = (altDown) => {
|
||
if (node._drawing || node._marquee || node._placing || node._hideBoxes || !node._lastMouseN) return;
|
||
applyHover(hoverTargets(node._lastMouseN, altDown, false));
|
||
};
|
||
canvasEl.addEventListener("pointerleave", () => {
|
||
if (hoveredCanvasNode === node) hoveredCanvasNode = null;
|
||
if (node._hoverTitle !== null || node._hoverBox !== null || (node._altPreview ?? -1) >= 0) {
|
||
node._hoverTitle = null; node._hoverBox = null; node._altPreview = -1; drawCanvas();
|
||
}
|
||
});
|
||
canvasEl.addEventListener("pointerenter", () => { hoveredCanvasNode = node; });
|
||
// H (while hovering the canvas): temporary background-only view (not serialized).
|
||
node._toggleHideBoxes = () => { node._hideBoxes = !node._hideBoxes; drawCanvas(); };
|
||
|
||
// ── inline description editing (double-click a region) ──
|
||
let inlineTa = null;
|
||
function closeInlineEditor() {
|
||
if (inlineTa) { inlineTa.remove(); inlineTa = null; }
|
||
}
|
||
function openInlineEditor(idx) {
|
||
closeInlineEditor();
|
||
const b = node._boxes[idx];
|
||
if (!b) return;
|
||
selectOnly(idx);
|
||
const dw = canvasEl.offsetWidth, dh = canvasEl.offsetHeight; // CSS display size
|
||
const ox = canvasEl.offsetLeft, oy = canvasEl.offsetTop;
|
||
const w = Math.min(dw, Math.max(70, b.w * dw));
|
||
const h = Math.min(dh, Math.max(42, b.h * dh));
|
||
// clamp so the editor stays inside the canvas (wrapper is overflow:hidden)
|
||
const left = Math.max(ox, Math.min(ox + b.x * dw, ox + dw - w));
|
||
const top = Math.max(oy, Math.min(oy + b.y * dh, oy + dh - h));
|
||
const ta = document.createElement("textarea");
|
||
ta.className = "ai2go-ideo-inline";
|
||
ta.value = b.desc || "";
|
||
ta.style.left = left + "px";
|
||
ta.style.top = top + "px";
|
||
ta.style.width = w + "px";
|
||
ta.style.height = h + "px";
|
||
ta.style.borderColor = (b.palette || []).find(Boolean) || "#46b4e6"; // first palette color, else accent
|
||
stopProp(ta);
|
||
wrap.appendChild(ta);
|
||
inlineTa = ta;
|
||
ta.focus(); ta.select();
|
||
const orig = b.desc || "";
|
||
let cancelled = false;
|
||
ta.addEventListener("input", () => { b.desc = ta.value; drawCanvas(); updateTokens(); });
|
||
ta.addEventListener("keydown", (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === "Escape") { cancelled = true; b.desc = orig; ta.blur(); }
|
||
else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) ta.blur();
|
||
});
|
||
ta.addEventListener("blur", () => {
|
||
if (!cancelled) b.desc = ta.value;
|
||
closeInlineEditor();
|
||
commit();
|
||
});
|
||
}
|
||
canvasEl.addEventListener("dblclick", (e) => {
|
||
if (node._hideBoxes) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const cands = boxesAt(mouseN(e)); // edit the active box if it's under the cursor, else topmost
|
||
const target = cands.find((c) => c.index === node._activeIdx) || cands[0];
|
||
if (target) openInlineEditor(target.index);
|
||
});
|
||
|
||
// Snapshot the current selection (or the active box) for the clipboard.
|
||
function copySelection() {
|
||
const idxs = node._selection.size ? [...node._selection].sort((a, b) => a - b)
|
||
: (node._activeIdx >= 0 ? [node._activeIdx] : []);
|
||
return idxs.filter((i) => !node._boxes[i].group).map((i) => JSON.parse(JSON.stringify(node._boxes[i])));
|
||
}
|
||
// Paste the clipboard regions as a group, centered under the cursor, keeping their layout.
|
||
function pasteBoxes() {
|
||
if (!copiedBoxes || !copiedBoxes.length) return;
|
||
const clones = copiedBoxes.map((b) => { const c = JSON.parse(JSON.stringify(b)); delete c.locked; delete c.id; delete c.parent; return c; }); // pasted = editable, fresh id, root
|
||
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
|
||
for (const b of clones) {
|
||
minx = Math.min(minx, b.x); miny = Math.min(miny, b.y);
|
||
maxx = Math.max(maxx, b.x + b.w); maxy = Math.max(maxy, b.y + b.h);
|
||
}
|
||
const gw = maxx - minx, gh = maxy - miny;
|
||
const m = node._lastMouseN;
|
||
let tx = m ? m.x - gw / 2 : minx + 0.03; // target group top-left
|
||
let ty = m ? m.y - gh / 2 : miny + 0.03;
|
||
tx = Math.max(0, Math.min(tx, 1 - gw)); // clamp group into the canvas
|
||
ty = Math.max(0, Math.min(ty, 1 - gh));
|
||
const dx = tx - minx, dy = ty - miny;
|
||
const start = node._boxes.length;
|
||
for (const b of clones) {
|
||
b.x = clamp01(b.x + dx); b.y = clamp01(b.y + dy);
|
||
delete b.nobbox; // pasted boxes are placed
|
||
node._boxes.push(b);
|
||
}
|
||
node._selection = new Set();
|
||
for (let i = start; i < node._boxes.length; i++) node._selection.add(i);
|
||
node._activeIdx = node._boxes.length - 1;
|
||
commit(); fitCanvas();
|
||
}
|
||
// Keyboard: Delete removes; Ctrl/Cmd C/V/D copy/paste/duplicate the active region.
|
||
// Canvas must be focused; stop the event so LiteGraph doesn't act on the node.
|
||
canvasEl.addEventListener("keydown", (e) => {
|
||
if (node._placing) {
|
||
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelPlacing(); }
|
||
return;
|
||
}
|
||
if (node._drawing || node._hideBoxes) return; // view-only while boxes are hidden (H)
|
||
const ctrl = e.ctrlKey || e.metaKey;
|
||
if ((e.key === "Delete" || e.key === "Backspace") && node._activeIdx >= 0) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
removeSelected(); commit(); fitCanvas(); // removes all selected (or the active one)
|
||
} else if (ctrl && e.key === "c" && node._activeIdx >= 0) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
copiedBoxes = copySelection();
|
||
} else if (ctrl && e.key === "v" && copiedBoxes) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
pasteBoxes();
|
||
} else if (ctrl && e.key === "d" && node._activeIdx >= 0) {
|
||
e.preventDefault(); e.stopPropagation();
|
||
copiedBoxes = copySelection();
|
||
pasteBoxes();
|
||
}
|
||
});
|
||
|
||
function onMove(e) {
|
||
if (!node._drawing) return;
|
||
const mN = mouseN(e);
|
||
const dN = { x: mN.x - node._dragStartN.x, y: mN.y - node._dragStartN.y };
|
||
if (Math.abs(dN.x) + Math.abs(dN.y) > 0.001) node._pendingCollapse = -1; // it's a drag, not a click
|
||
if (node._dragMode === "move" && node._groupStart) {
|
||
let dx = dN.x, dy = dN.y;
|
||
if (node.properties.snap) { // snap the group's movement to whole grid cells FIRST
|
||
const { sx, sy } = gridStep();
|
||
dx = Math.round(dx / sx) * sx;
|
||
dy = Math.round(dy / sy) * sy;
|
||
}
|
||
for (const i in node._groupStart) { // then clamp so the whole group stays in bounds (clamp wins over snap)
|
||
const s = node._groupStart[i];
|
||
dx = Math.min(Math.max(dx, -s.x), 1 - s.w - s.x);
|
||
dy = Math.min(Math.max(dy, -s.y), 1 - s.h - s.y);
|
||
}
|
||
for (const i in node._groupStart) {
|
||
const s = node._groupStart[i];
|
||
node._boxes[i] = { ...s, x: s.x + dx, y: s.y + dy };
|
||
if (node._selection.has(Number(i))) delete node._boxes[i].nobbox; // only directly-selected boxes get placed; pulled-in descendants keep nobbox
|
||
}
|
||
drawCanvas(); updateBboxLabel();
|
||
return;
|
||
}
|
||
if (node._groupStart && node._dragMode.startsWith("resize")) {
|
||
// Scale every selected box by the primary's resize, about the handle's fixed edge.
|
||
const a = node._boxAtStart, na = applyDrag(node._dragMode, a, dN);
|
||
const suf = node._dragMode.slice(7); // "tl"|"tr"|"bl"|"br"|"t"|"b"|"l"|"r"
|
||
const scaleX = (suf.includes("l") || suf.includes("r")) && a.w > 0 ? Math.max(0.02, na.w / a.w) : 1;
|
||
const scaleY = (suf.includes("t") || suf.includes("b")) && a.h > 0 ? Math.max(0.02, na.h / a.h) : 1;
|
||
const ax = suf.includes("l") ? a.x + a.w : a.x; // fixed (anchor) edges
|
||
const ay = suf.includes("t") ? a.y + a.h : a.y;
|
||
for (const i in node._groupStart) {
|
||
const s = node._groupStart[i];
|
||
node._boxes[i] = normalizeBox({
|
||
...s, x: ax + (s.x - ax) * scaleX, y: ay + (s.y - ay) * scaleY,
|
||
w: s.w * scaleX, h: s.h * scaleY,
|
||
});
|
||
if (node._selection.has(Number(i))) delete node._boxes[i].nobbox; // only directly-selected boxes get placed; pulled-in descendants keep nobbox
|
||
}
|
||
drawCanvas(); updateBboxLabel();
|
||
return;
|
||
}
|
||
const nb = snapBox(applyDrag(node._dragMode, node._boxAtStart, dN), node._dragMode);
|
||
delete nb.nobbox; // moving/resizing places the element (gives it a bbox)
|
||
node._boxes[node._activeIdx] = nb;
|
||
drawCanvas(); updateBboxLabel();
|
||
}
|
||
function onUp() {
|
||
if (!node._drawing) return;
|
||
node._drawing = false;
|
||
canvasEl.removeEventListener("pointermove", onMove);
|
||
canvasEl.removeEventListener("pointerup", onUp);
|
||
canvasEl.removeEventListener("pointercancel", onUp);
|
||
// a click (no drag) on empty space drops the placeholder box and deselects everything
|
||
const b = node._boxes[node._activeIdx];
|
||
if (b && (b.w < 0.005 || b.h < 0.005) && node._dragMode === "draw") {
|
||
node._boxes.splice(node._activeIdx, 1);
|
||
selectOnly(-1);
|
||
} else if (node._pendingCollapse >= 0) { // click (no drag) on a group member → keep only it
|
||
selectOnly(node._pendingCollapse);
|
||
}
|
||
node._pendingCollapse = -1; node._groupStart = null;
|
||
commit();
|
||
}
|
||
|
||
// ── marquee (rubber-band) selection: shift-drag ──
|
||
function marqueeRect() {
|
||
const m = node._marquee;
|
||
return { x: Math.min(m.x0, m.x), y: Math.min(m.y0, m.y),
|
||
w: Math.abs(m.x - m.x0), h: Math.abs(m.y - m.y0) };
|
||
}
|
||
function rectsOverlap(r, b) {
|
||
return r.x < b.x + b.w && r.x + r.w > b.x && r.y < b.y + b.h && r.y + r.h > b.y;
|
||
}
|
||
function startMarquee(mN, startHit) {
|
||
node._marquee = { x0: mN.x, y0: mN.y, x: mN.x, y: mN.y };
|
||
node._marqueeBase = new Set(node._selection); // additive: marquee unions with what's selected
|
||
node._marqueeStartHit = startHit; // for the shift-click (no drag) toggle fallback
|
||
node._marqueeActive = false;
|
||
canvasEl.focus();
|
||
canvasEl.addEventListener("pointermove", onMarqueeMove);
|
||
canvasEl.addEventListener("pointerup", onMarqueeUp);
|
||
canvasEl.addEventListener("pointercancel", onMarqueeUp);
|
||
drawCanvas();
|
||
}
|
||
function onMarqueeMove(e) {
|
||
if (!node._marquee) return;
|
||
const mN = mouseN(e);
|
||
node._marquee.x = mN.x; node._marquee.y = mN.y;
|
||
if (Math.abs(mN.x - node._marquee.x0) + Math.abs(mN.y - node._marquee.y0) > 0.01) node._marqueeActive = true;
|
||
if (node._marqueeActive) {
|
||
const r = marqueeRect();
|
||
const sel = new Set(node._marqueeBase);
|
||
node._boxes.forEach((b, i) => { if (!b.locked && rectsOverlap(r, b)) sel.add(i); }); // marquee skips locked
|
||
node._selection = sel;
|
||
if (node._activeIdx < 0 || !sel.has(node._activeIdx)) node._activeIdx = sel.size ? [...sel][0] : node._activeIdx;
|
||
}
|
||
drawCanvas();
|
||
}
|
||
function onMarqueeUp() {
|
||
canvasEl.removeEventListener("pointermove", onMarqueeMove);
|
||
canvasEl.removeEventListener("pointerup", onMarqueeUp);
|
||
canvasEl.removeEventListener("pointercancel", onMarqueeUp);
|
||
if (!node._marqueeActive && node._marqueeStartHit >= 0) { // shift-click on a box → toggle it
|
||
const idx = node._marqueeStartHit;
|
||
if (node._selection.has(idx) && node._selection.size > 1) {
|
||
node._selection.delete(idx);
|
||
if (node._activeIdx === idx) node._activeIdx = node._selection.values().next().value;
|
||
} else {
|
||
node._selection.add(idx); node._activeIdx = idx;
|
||
}
|
||
}
|
||
node._marquee = null; node._marqueeActive = false;
|
||
if (node._activeIdx >= 0 && !node._selection.has(node._activeIdx)) {
|
||
node._activeIdx = node._selection.size ? [...node._selection][0] : -1;
|
||
}
|
||
commit();
|
||
}
|
||
|
||
// ── duplicate placement: the new box follows the cursor until clicked ──
|
||
function placeFollower(mN) {
|
||
const b = node._boxes[node._activeIdx];
|
||
if (!b) return;
|
||
b.x = clamp01(Math.min(mN.x - b.w / 2, 1 - b.w));
|
||
b.y = clamp01(Math.min(mN.y - b.h / 2, 1 - b.h));
|
||
delete b.nobbox;
|
||
drawCanvas(); updateBboxLabel();
|
||
}
|
||
function startPlacing(srcIdx) {
|
||
const src = node._boxes[srcIdx];
|
||
if (!src || src.group) return; // groups aren't duplicated (they'd orphan)
|
||
const nb = JSON.parse(JSON.stringify(src));
|
||
delete nb.nobbox; delete nb.locked; delete nb.id; delete nb.parent; // a duplicate is editable, not born locked, fresh id, root
|
||
node._boxes.push(nb);
|
||
selectOnly(node._boxes.length - 1);
|
||
node._placing = true;
|
||
canvasEl.focus();
|
||
canvasEl.style.cursor = "move";
|
||
serialize(); renderPanel(); renderExplorer(); drawCanvas(); updateTokens();
|
||
}
|
||
function finishPlacing() {
|
||
if (!node._placing) return;
|
||
node._placing = false;
|
||
canvasEl.style.cursor = "crosshair";
|
||
commit(); fitCanvas();
|
||
}
|
||
function cancelPlacing() {
|
||
if (!node._placing) return;
|
||
node._placing = false;
|
||
canvasEl.style.cursor = "crosshair";
|
||
removeBox(node._activeIdx);
|
||
commit(); fitCanvas();
|
||
}
|
||
|
||
// ── right-click "layers" menu: list / select / delete / duplicate / reorder regions ──
|
||
function closeLayersMenu() {
|
||
if (node._layerMenu) { node._layerMenu.remove(); node._layerMenu = null; }
|
||
node._layerDismiss?.disarm(); node._layerDismiss = null;
|
||
}
|
||
function rowLabel(b) {
|
||
if (b.group) return "📁 " + (b.name || "Group");
|
||
if (b.type === "text") {
|
||
const t = b.text ? `"${b.text}"` : "";
|
||
return b.desc ? (t ? t + " — " + b.desc : b.desc) : t;
|
||
}
|
||
return b.desc || "";
|
||
}
|
||
function openLayersMenu(clientX, clientY) {
|
||
closeLayersMenu();
|
||
const menu = document.createElement("div");
|
||
menu.className = "ai2go-ideo-menu";
|
||
const hdr = document.createElement("div");
|
||
hdr.className = "ai2go-ideo-mhdr";
|
||
hdr.textContent = "Regions — top = front · click select · drag reorder";
|
||
// 01 at the top of the list = front-most (drawn on top); see _draw / boxesAt.
|
||
menu.appendChild(hdr);
|
||
const list = document.createElement("div");
|
||
menu.appendChild(list);
|
||
node._layerMenu = menu;
|
||
|
||
const renumber = () => Array.from(list.querySelectorAll(".ai2go-ideo-lrow")).forEach((row, k) => {
|
||
row.querySelector(".ai2go-ideo-lnum").textContent = String(k + 1).padStart(2, "0");
|
||
});
|
||
function buildRows() {
|
||
list.innerHTML = "";
|
||
if (!node._boxes.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "ai2go-ideo-mhdr"; empty.textContent = "No regions yet.";
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
node._boxes.forEach((b, i) => {
|
||
const row = document.createElement("div");
|
||
row.className = "ai2go-ideo-lrow" + (i === node._activeIdx ? " active" : "");
|
||
row._box = b;
|
||
const sw = document.createElement("div");
|
||
sw.className = "ai2go-ideo-lsw";
|
||
sw.style.background = (b.palette || []).find(Boolean) || "#8c8c8c";
|
||
const num = document.createElement("span");
|
||
num.className = "ai2go-ideo-lnum"; num.textContent = String(i + 1).padStart(2, "0");
|
||
const txt = document.createElement("span");
|
||
const label = rowLabel(b);
|
||
txt.className = "ai2go-ideo-ltext" + (label ? "" : " empty");
|
||
txt.textContent = label || (b.type === "text" ? "(text)" : "(empty)");
|
||
txt.title = label;
|
||
const lock = document.createElement("button");
|
||
lock.className = "ai2go-ideo-lbtn ai2go-ideo-lock" + (b.locked ? " on" : "");
|
||
lock.textContent = b.locked ? "🔒" : "🔓";
|
||
lock.title = b.locked ? "Unlock (allow moving/resizing)" : "Lock (freeze on the canvas)";
|
||
const dup = document.createElement("button");
|
||
dup.className = "ai2go-ideo-lbtn"; dup.textContent = "⧉";
|
||
dup.title = "Duplicate, then click on the canvas to place";
|
||
const del = document.createElement("button");
|
||
del.className = "ai2go-ideo-lbtn del"; del.textContent = "✕";
|
||
del.title = b.locked ? "Unlock to delete" : "Delete region";
|
||
del.disabled = !!b.locked;
|
||
row.append(sw, num, txt, lock, dup, del);
|
||
list.appendChild(row);
|
||
|
||
lock.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
b.locked = !b.locked;
|
||
if (b.locked) { // a locked box drops out of the selection
|
||
const idx = node._boxes.indexOf(b);
|
||
node._selection.delete(idx);
|
||
if (node._activeIdx === idx) node._activeIdx = node._selection.size ? [...node._selection][0] : -1;
|
||
}
|
||
commit(); buildRows();
|
||
});
|
||
|
||
row.addEventListener("click", () => {
|
||
if (row._dragged) { row._dragged = false; return; }
|
||
selectOnly(node._boxes.indexOf(b));
|
||
commit();
|
||
for (const r of list.querySelectorAll(".ai2go-ideo-lrow")) r.classList.toggle("active", r._box === b);
|
||
});
|
||
dup.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const idx = node._boxes.indexOf(b);
|
||
closeLayersMenu();
|
||
startPlacing(idx);
|
||
});
|
||
del.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const idx = node._boxes.indexOf(b);
|
||
if (idx < 0) return;
|
||
removeBox(idx); commit(); fitCanvas();
|
||
if (!node._boxes.length) { closeLayersMenu(); return; }
|
||
buildRows();
|
||
});
|
||
// drag-reorder (vertical FLIP, mirrors the palette swatch reorder)
|
||
row.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || e.target === lock || e.target === dup || e.target === del) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const sx = e.clientX, sy = e.clientY;
|
||
let dragging = false;
|
||
const move = (me) => {
|
||
if (!dragging) {
|
||
if (Math.abs(me.clientX - sx) + Math.abs(me.clientY - sy) < 4) return;
|
||
dragging = true; row.classList.add("dragging"); document.body.classList.add("ai2go-ideo-dragging");
|
||
}
|
||
for (const other of list.querySelectorAll(".ai2go-ideo-lrow")) {
|
||
if (other === row) continue;
|
||
const r = other.getBoundingClientRect();
|
||
if (me.clientY >= r.top && me.clientY <= r.bottom) {
|
||
const ref = me.clientY > r.top + r.height / 2 ? other.nextSibling : other;
|
||
if (ref === row || ref === row.nextSibling) break;
|
||
const els = Array.from(list.querySelectorAll(".ai2go-ideo-lrow"));
|
||
const prev = els.map((el) => el.getBoundingClientRect().top);
|
||
list.insertBefore(row, ref);
|
||
els.forEach((el, k) => { // FLIP: slide to new positions
|
||
const dy = prev[k] - el.getBoundingClientRect().top;
|
||
if (!dy) return;
|
||
el.style.transition = "none";
|
||
el.style.transform = `translateY(${dy}px)`;
|
||
el.getBoundingClientRect(); // flush
|
||
el.style.transition = ""; el.style.transform = "";
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
const up = () => {
|
||
document.removeEventListener("pointermove", move);
|
||
document.removeEventListener("pointerup", up);
|
||
document.removeEventListener("pointercancel", up);
|
||
document.body.classList.remove("ai2go-ideo-dragging");
|
||
if (dragging) {
|
||
row.classList.remove("dragging");
|
||
row._dragged = true; // suppress the trailing click
|
||
const active = node._boxes[node._activeIdx];
|
||
const order = Array.from(list.querySelectorAll(".ai2go-ideo-lrow")).map((el) => el._box);
|
||
if (order.length === node._boxes.length) node._boxes = order;
|
||
selectOnly(active ? node._boxes.indexOf(active) : -1); // reorder invalidates multi-select indices
|
||
renumber();
|
||
commit();
|
||
}
|
||
};
|
||
document.addEventListener("pointermove", move);
|
||
document.addEventListener("pointerup", up);
|
||
document.addEventListener("pointercancel", up);
|
||
});
|
||
});
|
||
}
|
||
buildRows();
|
||
|
||
document.body.appendChild(menu);
|
||
const r = menu.getBoundingClientRect(); // clamp into the viewport
|
||
let left = clientX, top = clientY;
|
||
if (left + r.width > window.innerWidth) left = window.innerWidth - r.width - 4;
|
||
if (top + r.height > window.innerHeight) top = window.innerHeight - r.height - 4;
|
||
menu.style.left = Math.max(4, left) + "px";
|
||
menu.style.top = Math.max(4, top) + "px";
|
||
|
||
node._layerDismiss = outsideDismiss(menu, () => closeLayersMenu());
|
||
node._layerDismiss.arm();
|
||
}
|
||
|
||
canvasEl.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (node._placing || node._hideBoxes) return;
|
||
closeInlineEditor();
|
||
openLayersMenu(e.clientX, e.clientY);
|
||
});
|
||
// Clear canvas: wipe only the boxes/selection. Caption text fields and the style palette stay put.
|
||
const clearCanvas = () => {
|
||
closeInlineEditor();
|
||
node._boxes = []; node._activeIdx = -1; node._selection = new Set();
|
||
commit(); fitCanvas();
|
||
};
|
||
// Clear all: full reset — boxes, palette, and every caption text widget (the "top" of the node).
|
||
const clearAll = () => {
|
||
clearCanvas();
|
||
node._stylePalette = [];
|
||
node._lastImported = ""; // a wired import re-seeds on the next run (serializeValue cache-busts)
|
||
for (const name of ["background", "high_level_description", "aesthetics", "lighting", "medium", "style"]) {
|
||
const w = findW(name);
|
||
if (w) { w.value = ""; w.callback?.(w.value, app.canvas, node); }
|
||
}
|
||
commit(); rebuildStylePalette(); updateTokens();
|
||
};
|
||
stopProp(clearCanvasBtn);
|
||
clearCanvasBtn.addEventListener("click", clearCanvas);
|
||
stopProp(clearBtn);
|
||
clearBtn.addEventListener("click", clearAll);
|
||
|
||
// ── build caption JSON (mirrors Python key order) ──
|
||
// pyJson: matches Python _dumps — indent=4, but scalar arrays stay on one line.
|
||
function pyJson(v, lvl = 0) {
|
||
if (v === null) return "null";
|
||
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
||
if (typeof v === "string") return JSON.stringify(v);
|
||
const pad = " ".repeat(lvl + 1), end = " ".repeat(lvl);
|
||
if (Array.isArray(v)) {
|
||
if (!v.length) return "[]";
|
||
if (v.every((x) => x === null || typeof x !== "object")) // scalar array → inline
|
||
return "[" + v.map((x) => pyJson(x, lvl)).join(", ") + "]";
|
||
return "[\n" + v.map((x) => pad + pyJson(x, lvl + 1)).join(",\n") + "\n" + end + "]";
|
||
}
|
||
const keys = Object.keys(v);
|
||
if (!keys.length) return "{}";
|
||
return "{\n" + keys.map((k) => pad + JSON.stringify(k) + ": " + pyJson(v[k], lvl + 1)).join(",\n") + "\n" + end + "}";
|
||
}
|
||
function getW(name) { const w = findW(name); return w ? w.value : ""; }
|
||
function cleanPalette(arr) { return (arr || []).filter((c) => c).map((c) => c.toUpperCase()); }
|
||
function normBboxJS(b) {
|
||
const c = (v) => Math.max(0, Math.min(1000, Math.round(v * 1000)));
|
||
let ymin = c(b.y), xmin = c(b.x), ymax = c(b.y + b.h), xmax = c(b.x + b.w);
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
return [ymin, xmin, ymax, xmax];
|
||
}
|
||
// Caption bbox: 0-1000 grid (default) or absolute pixels (scaled by width/height) — must match the
|
||
// Python output. normBboxJS stays 0-1000 for the editor's grid input field, so keep this separate.
|
||
function captionBboxJS(b) {
|
||
const abs = (coordWidget?.value) === "absolute", xy = (orderWidget?.value) === "xy";
|
||
const sx = abs ? (wWidget?.value || 1000) : 1000, sy = abs ? (hWidget?.value || 1000) : 1000;
|
||
const cx = (v) => Math.max(0, Math.min(sx, Math.round(v * sx)));
|
||
const cy = (v) => Math.max(0, Math.min(sy, Math.round(v * sy)));
|
||
let ymin = cy(b.y), xmin = cx(b.x), ymax = cy(b.y + b.h), xmax = cx(b.x + b.w);
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
return xy ? [xmin, ymin, xmax, ymax] : [ymin, xmin, ymax, xmax];
|
||
}
|
||
function buildCaptionObject() {
|
||
const cap = {};
|
||
if ((getW("high_level_description") || "").trim()) cap.high_level_description = getW("high_level_description");
|
||
const styleW = findW("style");
|
||
const kind = styleW ? styleW.value : "none";
|
||
if (kind !== "none") {
|
||
const sd = { aesthetics: getW("aesthetics"), lighting: getW("lighting") };
|
||
if (kind === "photo") { sd.photo = getW("style.photo") || ""; sd.medium = getW("medium"); }
|
||
else { sd.medium = getW("medium"); sd.art_style = getW("style.art_style") || ""; }
|
||
const pal = cleanPalette(node._stylePalette);
|
||
if (pal.length) sd.color_palette = pal;
|
||
cap.style_description = sd;
|
||
}
|
||
const elements = node._boxes.filter((b) => !b.group && !isDisabled(b)).map((b) => { // skip groups (editor-only) + disabled regions
|
||
const etype = b.type === "text" ? "text" : "obj";
|
||
const el = { type: etype };
|
||
if (!b.nobbox) el.bbox = captionBboxJS(b); // unplaced elements omit bbox
|
||
if (etype === "text") el.text = b.text || "";
|
||
el.desc = b.desc || "";
|
||
const pal = cleanPalette(b.palette).slice(0, MAX_ELEM_COLORS);
|
||
if (pal.length) el.color_palette = pal;
|
||
return el;
|
||
});
|
||
cap.compositional_deconstruction = { background: getW("background"), elements };
|
||
return cap;
|
||
}
|
||
function stringifyCaption(cap) { return (formatWidget?.value) === "pretty" ? pyJson(cap) : JSON.stringify(cap); } // compact by default; matches the node output
|
||
function buildCaption() { return stringifyCaption(buildCaptionObject()); } // clean caption — token estimate + Python-matching
|
||
// Clipboard / template form: the clean caption PLUS an "_ai2go" sidecar carrying the full editor layout
|
||
// (groups + parent/id/name/etc). KJNodes and the model ignore the unknown key; OUR Paste reads it to
|
||
// restore the hierarchy losslessly. Added only when there's hierarchy/groups worth preserving, so plain
|
||
// scenes stay clean. The Python `prompt` output never includes it.
|
||
function buildClipboardCaption() {
|
||
const cap = buildCaptionObject();
|
||
if (node._boxes.some((b) => b.group || b.parent != null)) cap._ai2go = { v: 1, boxes: node._boxes };
|
||
return stringifyCaption(cap);
|
||
}
|
||
// Rough token estimate (~chars/4); exact count needs the Qwen tokenizer.
|
||
function updateTokens() {
|
||
const n = Math.ceil(buildCaption().length / 4);
|
||
tokenSpan.textContent = "~" + n + " tok";
|
||
// grey <256 (sparse) · green healthy · orange nearing · red ≥2048 (model hard cap)
|
||
tokenSpan.style.color = n >= 2048 ? "#e05555" : n >= 1792 ? "#e6a23c" : n >= 256 ? "#6cc06c" : "#888";
|
||
}
|
||
async function doCopy() {
|
||
const txt = buildClipboardCaption(); // includes the _ai2go layout sidecar when there are groups/parents
|
||
try { await navigator.clipboard.writeText(txt); copyBtn.textContent = "Copied"; setTimeout(() => (copyBtn.textContent = "Copy"), 900); }
|
||
catch (e) { window.prompt("Copy the caption JSON:", txt); }
|
||
}
|
||
stopProp(copyBtn);
|
||
copyBtn.addEventListener("click", doCopy);
|
||
|
||
// ── import a caption JSON and populate the node ──
|
||
function setWidgetVal(name, val) {
|
||
const w = findW(name);
|
||
if (w) { w.value = val; w.callback?.(val); }
|
||
}
|
||
function bboxElemToBox(el, idx) {
|
||
if (!el || typeof el !== "object") return null;
|
||
const box = { type: el.type === "text" ? "text" : "obj",
|
||
text: el.text || "", desc: el.desc || "",
|
||
palette: Array.isArray(el.color_palette) ? el.color_palette.slice() : [] };
|
||
const bb = el.bbox;
|
||
if (Array.isArray(bb) && bb.length === 4) {
|
||
const [ymin, xmin, ymax, xmax] = bb;
|
||
box.x = xmin / 1000; box.y = ymin / 1000; box.w = (xmax - xmin) / 1000; box.h = (ymax - ymin) / 1000;
|
||
} else {
|
||
// No bbox: "unplaced" element — small placeholder, flagged so export omits bbox.
|
||
const k = (idx || 0) % 6;
|
||
box.x = 0.03 + k * 0.035; box.y = 0.03 + k * 0.035; box.w = 0.22; box.h = 0.14;
|
||
box.nobbox = true;
|
||
}
|
||
return box;
|
||
}
|
||
function applyCaption(cap) {
|
||
const cd = (cap && cap.compositional_deconstruction) || {};
|
||
const els = Array.isArray(cd.elements) ? cd.elements : [];
|
||
const side = cap && cap._ai2go;
|
||
if (side && Array.isArray(side.boxes) && side.boxes.some((b) => b && typeof b.x === "number")) {
|
||
node._boxes = side.boxes.filter((b) => b && typeof b.x === "number"); // AI2Go sidecar → full restore (groups + hierarchy)
|
||
} else {
|
||
node._boxes = els.map((el, i) => bboxElemToBox(el, i)).filter(Boolean); // plain caption (KJ / Ideogram) → flat regions
|
||
}
|
||
selectOnly(node._boxes.length ? 0 : -1);
|
||
setWidgetVal("high_level_description", cap.high_level_description || "");
|
||
setWidgetVal("background", cd.background || "");
|
||
const sd = cap.style_description || {};
|
||
let kind = "none";
|
||
if (typeof sd.photo === "string") kind = "photo";
|
||
else if (typeof sd.art_style === "string") kind = "art_style";
|
||
const styleW = findW("style");
|
||
if (styleW) styleW.value = kind; // setter synchronously rebuilds sub-widgets
|
||
if (kind === "photo") setWidgetVal("style.photo", sd.photo || "");
|
||
else if (kind === "art_style") setWidgetVal("style.art_style", sd.art_style || "");
|
||
setWidgetVal("aesthetics", sd.aesthetics || "");
|
||
setWidgetVal("lighting", sd.lighting || "");
|
||
setWidgetVal("medium", sd.medium || "");
|
||
node._stylePalette = Array.isArray(sd.color_palette) ? sd.color_palette.slice() : [];
|
||
}
|
||
// Lenient fixer: slice the outermost {...} (drops ``` fences / prose), then strip trailing
|
||
// commas before } or ] — the string alt matches first so quoted commas are left untouched.
|
||
function repairJson(s) {
|
||
const i = s.indexOf("{"), j = s.lastIndexOf("}");
|
||
const t = (i !== -1 && j > i) ? s.slice(i, j + 1) : s;
|
||
return t.replace(/("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, (m, str, close) => str || close);
|
||
}
|
||
function tryParseCaption(t) {
|
||
if (!t) return null;
|
||
for (const cand of [t, repairJson(t)]) {
|
||
try { const o = JSON.parse(cand); if (o && typeof o === "object" && o.compositional_deconstruction) return o; }
|
||
catch (e) {}
|
||
}
|
||
return null;
|
||
}
|
||
// Apply a parsed caption to the editor and refresh everything.
|
||
function loadCaption(cap) {
|
||
closeInlineEditor();
|
||
// Setting the multiline widgets + rebuilding the style sub-widget makes ComfyUI grow the node;
|
||
// an import shouldn't change its size, so restore it (now and after the deferred re-arrange).
|
||
const sz = node.size ? [node.size[0], node.size[1]] : null;
|
||
applyCaption(cap);
|
||
if (sz) { node.setSize(sz); requestAnimationFrame(() => node.setSize(sz)); }
|
||
syncCanvasToDims(); commit(); rebuildStylePalette(); fitCanvas();
|
||
}
|
||
// Append a caption's regions to the current canvas (keeps existing boxes + caption fields).
|
||
function insertCaptionBoxes(cap) {
|
||
closeInlineEditor();
|
||
const cd = (cap && cap.compositional_deconstruction) || {};
|
||
const els = Array.isArray(cd.elements) ? cd.elements : [];
|
||
const added = els.map((el, i) => bboxElemToBox(el, i)).filter(Boolean);
|
||
if (!added.length) return;
|
||
const start = node._boxes.length;
|
||
node._boxes.push(...added);
|
||
node._selection = new Set(); // select the inserted regions
|
||
for (let i = start; i < node._boxes.length; i++) node._selection.add(i);
|
||
node._activeIdx = node._boxes.length - 1;
|
||
commit(); fitCanvas();
|
||
}
|
||
async function doImport() {
|
||
let cap = null, txt = "";
|
||
try { txt = (await navigator.clipboard.readText() || "").trim(); cap = tryParseCaption(txt); } catch (e) {}
|
||
if (!cap) { txt = (window.prompt("Paste Ideogram 4 caption JSON:", "") || "").trim(); cap = tryParseCaption(txt); }
|
||
if (!cap) { if (txt) alert("Not a valid Ideogram 4 caption JSON (needs 'compositional_deconstruction')."); return; }
|
||
loadCaption(cap);
|
||
}
|
||
stopProp(importBtn);
|
||
importBtn.addEventListener("click", doImport);
|
||
|
||
// Populate the editor from a caption pushed back by execute() when import_json
|
||
// is connected (a connected socket can't be read in the frontend directly).
|
||
function applyImported(capStr) {
|
||
if (!capStr) return;
|
||
// "always" mode re-applies even an unchanged import so the editor snaps back to the
|
||
// authoritative JSON after edits; "when empty" keeps the guard so edits stick.
|
||
const always = findW("import_mode")?.value === "always";
|
||
if (capStr === node._lastImported && !always) return;
|
||
const cap = tryParseCaption(capStr);
|
||
if (!cap) return;
|
||
node._lastImported = capStr;
|
||
loadCaption(cap);
|
||
}
|
||
chainCallback(node, "onExecuted", function (message) {
|
||
if (message?.caption) applyImported(message.caption[0]);
|
||
// Seed regions from the bboxes input only when the editor is empty, so user-drawn/edited
|
||
// regions are never overwritten.
|
||
if (message?.boxes && !node._boxes.length) {
|
||
const seeded = JSON.parse(message.boxes[0]);
|
||
if (Array.isArray(seeded) && seeded.length) {
|
||
node._boxes = seeded.filter((b) => b && typeof b.x === "number" && typeof b.w === "number");
|
||
selectOnly(node._boxes.length ? 0 : -1);
|
||
commit(); fitCanvas();
|
||
}
|
||
}
|
||
// Reflect resolved width/height (e.g. from connected inputs) in the canvas aspect.
|
||
// A connected background image governs the aspect itself, so skip then.
|
||
if (message?.dims && !node._bgImg) {
|
||
const [w, h] = message.dims;
|
||
if (wWidget && w) wWidget.value = w;
|
||
if (hWidget && h) hWidget.value = h;
|
||
syncCanvasToDims(); fitCanvas();
|
||
}
|
||
});
|
||
|
||
// ── property panel ──
|
||
function stopProp(el) {
|
||
el.addEventListener("wheel", (e) => e.stopPropagation());
|
||
// Let middle-click (button 1) bubble so it reaches the dock's pan handler; block left/right so
|
||
// dock controls don't drag/select the node behind them.
|
||
for (const ev of ["mousedown", "pointerdown"]) el.addEventListener(ev, (e) => { if (e.button !== 1) e.stopPropagation(); });
|
||
}
|
||
// Color swatches: onEdit on change, onStruct on add/remove/reorder. Shared by both palettes.
|
||
// Pointer-based drag (HTML5 DnD is unreliable inside LiteGraph DOM widgets) with live reorder.
|
||
function buildSwatchRow(container, arr, max, onEdit, onStruct) {
|
||
arr.forEach((hex, i) => {
|
||
const sw = document.createElement("div");
|
||
sw.className = "ai2go-ideo-sw";
|
||
sw.style.background = hex;
|
||
sw.dataset.hex = hex;
|
||
sw.title = "Click edit · drag reorder · Ctrl+C/V copy/paste hex · right-click remove";
|
||
const inp = document.createElement("input");
|
||
inp.type = "color"; inp.value = hex;
|
||
sw.appendChild(inp);
|
||
container.appendChild(sw);
|
||
const setColor = (hex2) => { arr[i] = hex2; inp.value = hex2; sw.style.background = hex2; sw.dataset.hex = hex2; onEdit(); };
|
||
inp.addEventListener("input", () => setColor(inp.value));
|
||
sw.addEventListener("pointerenter", () => { hoveredSwatch = { sw, setColor }; });
|
||
sw.addEventListener("pointerleave", () => { if (hoveredSwatch && hoveredSwatch.sw === sw) hoveredSwatch = null; });
|
||
sw.addEventListener("wheel", (e) => e.stopPropagation());
|
||
sw.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); arr.splice(i, 1); onStruct(); });
|
||
sw.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
try { sw.setPointerCapture(e.pointerId); } catch (e2) {} // capture so Nodes 2.0's WidgetDOM .stop can't swallow the drag
|
||
const sx = e.clientX, sy = e.clientY;
|
||
let dragging = false;
|
||
const move = (me) => {
|
||
if (!dragging) {
|
||
if (Math.abs(me.clientX - sx) + Math.abs(me.clientY - sy) < 4) return;
|
||
dragging = true; sw.classList.add("dragging"); document.body.classList.add("ai2go-ideo-dragging");
|
||
}
|
||
for (const other of container.querySelectorAll(".ai2go-ideo-sw")) {
|
||
if (other === sw) continue;
|
||
const r = other.getBoundingClientRect();
|
||
if (me.clientX >= r.left && me.clientX <= r.right && me.clientY >= r.top - 6 && me.clientY <= r.bottom + 6) {
|
||
const ref = me.clientX > r.left + r.width / 2 ? other.nextSibling : other;
|
||
if (ref === sw || ref === sw.nextSibling) break; // already there
|
||
const els = Array.from(container.querySelectorAll(".ai2go-ideo-sw"));
|
||
const prev = els.map((el) => el.getBoundingClientRect().left);
|
||
container.insertBefore(sw, ref);
|
||
els.forEach((el, k) => { // FLIP: slide to new positions
|
||
const dx = prev[k] - el.getBoundingClientRect().left;
|
||
if (!dx) return;
|
||
el.style.transition = "none";
|
||
el.style.transform = `translateX(${dx}px)`;
|
||
el.getBoundingClientRect(); // flush
|
||
el.style.transition = ""; el.style.transform = "";
|
||
});
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
const up = () => {
|
||
sw.removeEventListener("pointermove", move);
|
||
sw.removeEventListener("pointerup", up);
|
||
sw.removeEventListener("pointercancel", up);
|
||
document.body.classList.remove("ai2go-ideo-dragging");
|
||
if (dragging) {
|
||
sw.classList.remove("dragging");
|
||
const order = Array.from(container.querySelectorAll(".ai2go-ideo-sw")).map((el) => el.dataset.hex);
|
||
if (order.length === arr.length) { arr.length = 0; arr.push(...order); }
|
||
onStruct();
|
||
} else {
|
||
inp.click(); // no drag → treat as click, open the picker
|
||
}
|
||
};
|
||
sw.addEventListener("pointermove", move);
|
||
sw.addEventListener("pointerup", up);
|
||
sw.addEventListener("pointercancel", up);
|
||
});
|
||
});
|
||
if (arr.length < max) {
|
||
const add = document.createElement("button");
|
||
add.className = "ai2go-ideo-btn"; add.textContent = "+";
|
||
add.title = "Add a color (uses the clipboard color if it is one)";
|
||
stopProp(add);
|
||
add.addEventListener("click", async () => {
|
||
let col = "#ffffff";
|
||
try { const c = parseColorString(await navigator.clipboard.readText()); if (c) col = c; } catch (e) {}
|
||
arr.push(col); onStruct();
|
||
});
|
||
container.appendChild(add);
|
||
}
|
||
}
|
||
|
||
// Swatch color changed (no add/remove): persist + repaint.
|
||
function swatchEdit() { serialize(); drawCanvas(); }
|
||
|
||
function rebuildStylePalette() {
|
||
while (styleBar.children.length > 1) styleBar.removeChild(styleBar.lastChild);
|
||
buildSwatchRow(styleBar, node._stylePalette, MAX_STYLE_COLORS,
|
||
swatchEdit,
|
||
() => { swatchEdit(); rebuildStylePalette(); fitCanvas(); });
|
||
}
|
||
|
||
// Textarea that flexes to fill the prompt panel (whose height is set by the splitter).
|
||
function makeArea(field, value, placeholder, onInput) {
|
||
const ta = document.createElement("textarea");
|
||
ta.className = "ai2go-ideo-area";
|
||
ta.placeholder = placeholder;
|
||
ta.value = value || "";
|
||
stopProp(ta);
|
||
ta.addEventListener("input", onInput);
|
||
return ta;
|
||
}
|
||
let bboxPx = null, bboxGrid = null; // editable bbox fields: pixels and 0–1000 grid
|
||
function dims() { return [wWidget ? wWidget.value : 1024, hWidget ? hWidget.value : 1024]; }
|
||
function boxToPx(b) { // same order as the grid: ymin, xmin, ymax, xmax
|
||
const [W, H] = dims();
|
||
return [Math.round(b.y * H), Math.round(b.x * W), Math.round((b.y + b.h) * H), Math.round((b.x + b.w) * W)];
|
||
}
|
||
function setField(inp, b, fn) { // skip while the field is being edited
|
||
if (!inp || document.activeElement === inp) return;
|
||
inp.value = (!b || b.nobbox) ? "" : fn(b).join(", ");
|
||
}
|
||
function updateBboxLabel() {
|
||
const b = node._boxes[node._activeIdx];
|
||
setField(bboxPx, b, boxToPx);
|
||
setField(bboxGrid, b, normBboxJS);
|
||
}
|
||
function parse4(inp) {
|
||
const nums = inp.value.split(/[,\s]+/).map(Number).filter((n) => !isNaN(n));
|
||
return nums.length === 4 ? nums : null;
|
||
}
|
||
function commitPxEdit() {
|
||
const b = node._boxes[node._activeIdx]; if (!b || !bboxPx) return;
|
||
const nums = parse4(bboxPx); if (!nums) { updateBboxLabel(); return; }
|
||
const [W, H] = dims();
|
||
let [ymin, xmin, ymax, xmax] = nums; // ymin, xmin, ymax, xmax (matches grid)
|
||
ymin = Math.max(0, Math.min(H, ymin)); ymax = Math.max(0, Math.min(H, ymax));
|
||
xmin = Math.max(0, Math.min(W, xmin)); xmax = Math.max(0, Math.min(W, xmax));
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
b.y = ymin / H; b.x = xmin / W; b.h = (ymax - ymin) / H; b.w = (xmax - xmin) / W;
|
||
delete b.nobbox; commit(); fitCanvas();
|
||
}
|
||
function commitGridEdit() {
|
||
const b = node._boxes[node._activeIdx]; if (!b || !bboxGrid) return;
|
||
const nums = parse4(bboxGrid); if (!nums) { updateBboxLabel(); return; }
|
||
let [ymin, xmin, ymax, xmax] = nums.map((n) => Math.max(0, Math.min(1000, n)));
|
||
if (ymin > ymax) [ymin, ymax] = [ymax, ymin];
|
||
if (xmin > xmax) [xmin, xmax] = [xmax, xmin];
|
||
b.y = ymin / 1000; b.x = xmin / 1000; b.h = (ymax - ymin) / 1000; b.w = (xmax - xmin) / 1000;
|
||
delete b.nobbox; commit(); fitCanvas();
|
||
}
|
||
function makeBboxField(placeholder, title, onCommit) {
|
||
const inp = document.createElement("input");
|
||
inp.type = "text"; inp.className = "ai2go-ideo-bbox";
|
||
inp.placeholder = placeholder; inp.title = title;
|
||
stopProp(inp);
|
||
inp.addEventListener("keydown", (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === "Enter") inp.blur();
|
||
else if (e.key === "Escape") { updateBboxLabel(); inp.blur(); }
|
||
});
|
||
inp.addEventListener("change", onCommit);
|
||
return inp;
|
||
}
|
||
function renderPanel() {
|
||
for (const ro of node._areaObservers) ro.disconnect();
|
||
node._areaObservers = [];
|
||
panel.innerHTML = "";
|
||
const b = node._boxes[node._activeIdx];
|
||
if (!b) {
|
||
hint.textContent = "";
|
||
const p = document.createElement("div");
|
||
p.style.color = "#888";
|
||
p.textContent = node._boxes.length ? "Click a region to edit it." : "No regions yet.";
|
||
panel.appendChild(p);
|
||
return;
|
||
}
|
||
if (b.group) { // groups: name + frame color only (editor-only, never exported)
|
||
hint.textContent = "";
|
||
const gt = document.createElement("b");
|
||
gt.style.color = (b.palette || []).find(Boolean) || "#46b4e6";
|
||
gt.textContent = "📁 " + (b.name || "Group");
|
||
hint.appendChild(gt);
|
||
const note = document.createElement("div");
|
||
note.style.color = "#888"; note.textContent = "Group (organizer) — not exported. Move it to move its members; resize its frame to scale them.";
|
||
panel.appendChild(note);
|
||
const nameRow = document.createElement("div"); nameRow.className = "ai2go-ideo-row";
|
||
const nl = document.createElement("span"); nl.textContent = "name:"; nameRow.appendChild(nl);
|
||
const ni = document.createElement("input"); ni.type = "text"; ni.className = "ai2go-ideo-bbox"; ni.style.flex = "1 1 auto"; ni.value = b.name || "Group";
|
||
stopProp(ni);
|
||
ni.addEventListener("input", () => { b.name = ni.value; touch(); });
|
||
ni.addEventListener("keydown", (ev) => { ev.stopPropagation(); if (ev.key === "Enter") ni.blur(); });
|
||
nameRow.appendChild(ni); panel.appendChild(nameRow);
|
||
const palRow = document.createElement("div"); palRow.className = "ai2go-ideo-row";
|
||
const pl2 = document.createElement("span"); pl2.textContent = "frame:"; palRow.appendChild(pl2);
|
||
b.palette = b.palette || [];
|
||
buildSwatchRow(palRow, b.palette, 1, swatchEdit, commit);
|
||
panel.appendChild(palRow);
|
||
const actRow = document.createElement("div"); actRow.className = "ai2go-ideo-row";
|
||
const dupBtn = document.createElement("button"); dupBtn.className = "ai2go-ideo-btn"; dupBtn.textContent = "Duplicate"; dupBtn.title = "Duplicate this group and all its members";
|
||
stopProp(dupBtn); dupBtn.addEventListener("click", () => duplicateGroup(b.id));
|
||
const mhBtn = document.createElement("button"); mhBtn.className = "ai2go-ideo-btn"; mhBtn.textContent = "Mirror ⇆"; mhBtn.title = "Flip the members left ↔ right within the group";
|
||
stopProp(mhBtn); mhBtn.addEventListener("click", () => mirrorGroup(b.id, "h"));
|
||
const mvBtn = document.createElement("button"); mvBtn.className = "ai2go-ideo-btn"; mvBtn.textContent = "Mirror ⇅"; mvBtn.title = "Flip the members top ↕ bottom within the group";
|
||
stopProp(mvBtn); mvBtn.addEventListener("click", () => mirrorGroup(b.id, "v"));
|
||
actRow.append(dupBtn, mhBtn, mvBtn);
|
||
panel.appendChild(actRow);
|
||
return;
|
||
}
|
||
const col = (b.palette || []).find(Boolean) || "#bbb";
|
||
const selN = node._selection.size;
|
||
// Build with DOM + style.color (a CSS value) — never innerHTML — since col comes from
|
||
// box data that may be loaded from an untrusted template/import (avoids HTML injection).
|
||
hint.textContent = "";
|
||
const tag = document.createElement("b");
|
||
tag.style.color = col; tag.textContent = "region " + (node._activeIdx + 1);
|
||
hint.appendChild(tag);
|
||
if (selN > 1) {
|
||
const s = document.createElement("span");
|
||
s.style.color = "#888"; s.textContent = ` (${selN} selected)`;
|
||
hint.appendChild(s);
|
||
}
|
||
|
||
// type toggle
|
||
const typeRow = document.createElement("div");
|
||
typeRow.className = "ai2go-ideo-row";
|
||
const lbl = document.createElement("span"); lbl.textContent = "type:"; typeRow.appendChild(lbl);
|
||
for (const t of ["obj", "text"]) {
|
||
const btn = document.createElement("button");
|
||
btn.className = "ai2go-ideo-btn" + (b.type === t ? " active" : "");
|
||
btn.textContent = t;
|
||
stopProp(btn);
|
||
btn.addEventListener("click", () => { b.type = t; commit(); });
|
||
typeRow.appendChild(btn);
|
||
}
|
||
const pxLbl = document.createElement("span");
|
||
pxLbl.textContent = "px:"; pxLbl.style.cssText = "margin-left:auto; color:#888;";
|
||
typeRow.appendChild(pxLbl);
|
||
bboxPx = makeBboxField("ymin, xmin, ymax, xmax", "Pixel bbox (of the node's width/height): ymin, xmin, ymax, xmax — editable", commitPxEdit);
|
||
typeRow.appendChild(bboxPx);
|
||
const gl = document.createElement("span");
|
||
gl.textContent = "out:"; gl.style.color = "#888";
|
||
typeRow.appendChild(gl);
|
||
bboxGrid = makeBboxField("ymin, xmin, ymax, xmax", "Exported bbox on the 0–1000 grid: ymin, xmin, ymax, xmax — editable", commitGridEdit);
|
||
typeRow.appendChild(bboxGrid);
|
||
updateBboxLabel();
|
||
panel.appendChild(typeRow);
|
||
|
||
// text (only for text type)
|
||
if (b.type === "text") {
|
||
panel.appendChild(makeArea("text", b.text, "text to render (verbatim)",
|
||
function () { b.text = this.value; touch(); }));
|
||
}
|
||
|
||
panel.appendChild(makeArea("desc", b.desc, "description of this region",
|
||
function () { b.desc = this.value; touch(); }));
|
||
|
||
// palette
|
||
const palRow = document.createElement("div");
|
||
palRow.className = "ai2go-ideo-row";
|
||
const pl = document.createElement("span"); pl.textContent = "colors:"; palRow.appendChild(pl);
|
||
b.palette = b.palette || [];
|
||
buildSwatchRow(palRow, b.palette, MAX_ELEM_COLORS, swatchEdit, commit);
|
||
panel.appendChild(palRow);
|
||
}
|
||
|
||
// ── Object Explorer list ── (persistent region list; two-way selection with the canvas)
|
||
// Rebuilt on every commit(), so it always mirrors node._boxes / _activeIdx / _selection.
|
||
function renderExplorer() {
|
||
if (!exList) return;
|
||
exList.innerHTML = "";
|
||
if (!node._boxes.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "ai2go-ideo-mhdr"; empty.textContent = "No regions yet — drag on the canvas to add one.";
|
||
exList.appendChild(empty);
|
||
return;
|
||
}
|
||
// Build the hierarchy: children grouped by parent id (kept in array / z-order); roots under "__root__".
|
||
const kids = new Map();
|
||
const idsPresent = new Set(node._boxes.map((b) => b.id));
|
||
for (const b of node._boxes) {
|
||
const key = (b.parent != null && idsPresent.has(b.parent)) ? b.parent : "__root__";
|
||
if (!kids.has(key)) kids.set(key, []);
|
||
kids.get(key).push(b);
|
||
}
|
||
|
||
const mkRow = (b, depth) => {
|
||
const i = node._boxes.indexOf(b);
|
||
const hasKids = kids.has(b.id);
|
||
const row = document.createElement("div");
|
||
// active row solid; other selected rows also get the .active ring
|
||
row.className = "ai2go-ideo-lrow" + ((i === node._activeIdx || node._selection.has(i)) ? " active" : "") + (isDisabled(b) ? " disabled" : "");
|
||
row.style.paddingLeft = (4 + depth * 13) + "px"; // indentation = tree depth
|
||
row._box = b;
|
||
const ownOff = !!b.disabled;
|
||
const ancestorOff = b.parent != null && isDisabled(boxById(b.parent)); // a parent group (or higher) is off
|
||
const effOff = ownOff || ancestorOff;
|
||
const enableBtn = document.createElement("button"); // enable/disable toggle (a group is the master switch for its members)
|
||
enableBtn.className = "ai2go-ideo-lbtn ai2go-ideo-en" + (effOff ? " off" : "");
|
||
enableBtn.textContent = effOff ? "🚫" : "👁"; // EFFECTIVE visibility: a child of a disabled group reads as hidden
|
||
if (ancestorOff) { // master-switched off by its group → not individually toggleable
|
||
enableBtn.disabled = true; // flip the group instead
|
||
enableBtn.title = "Hidden by its group — toggle the group to show it";
|
||
} else {
|
||
enableBtn.title = ownOff ? "Disabled — click to enable"
|
||
: "Enabled — click to disable (hides it" + (b.group ? " and its members" : "") + " from the canvas and the prompt)";
|
||
}
|
||
stopProp(enableBtn);
|
||
enableBtn.addEventListener("click", (e) => { e.stopPropagation(); b.disabled = !b.disabled; commit(); });
|
||
const tri = document.createElement("span");
|
||
tri.className = "ai2go-ideo-exptri" + (hasKids ? "" : " leaf");
|
||
tri.textContent = hasKids ? (b.collapsed ? "▶" : "▼") : "•";
|
||
if (hasKids) tri.title = b.collapsed ? "Expand children" : "Collapse children";
|
||
const sw = document.createElement("div");
|
||
sw.className = "ai2go-ideo-lsw";
|
||
sw.style.background = (b.palette || []).find(Boolean) || "#8c8c8c";
|
||
const num = document.createElement("span");
|
||
num.className = "ai2go-ideo-lnum"; num.textContent = String(i + 1).padStart(2, "0");
|
||
const txt = document.createElement("span");
|
||
const label = rowLabel(b);
|
||
txt.className = "ai2go-ideo-ltext" + (label ? "" : " empty");
|
||
txt.textContent = label || (b.type === "text" ? "(text)" : "(empty)");
|
||
txt.title = label;
|
||
const lock = document.createElement("button");
|
||
lock.className = "ai2go-ideo-lbtn ai2go-ideo-lock" + (b.locked ? " on" : "");
|
||
lock.textContent = b.locked ? "🔒" : "🔓";
|
||
lock.title = b.locked ? "Unlock (allow moving/resizing)" : "Lock (freeze on the canvas)";
|
||
const dup = document.createElement("button");
|
||
dup.className = "ai2go-ideo-lbtn"; dup.textContent = "⧉";
|
||
dup.title = "Duplicate, then click on the canvas to place";
|
||
if (b.group) dup.style.display = "none"; // groups aren't duplicated
|
||
const del = document.createElement("button");
|
||
del.className = "ai2go-ideo-lbtn del"; del.textContent = "✕";
|
||
del.title = b.locked ? "Unlock to delete" : "Delete region (children promote to its parent)";
|
||
del.disabled = !!b.locked;
|
||
row.append(tri, enableBtn, sw, num, txt, lock, dup, del);
|
||
exList.appendChild(row);
|
||
|
||
if (hasKids) tri.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
b.collapsed = !b.collapsed;
|
||
serialize(); renderExplorer(); flushChange(); // persist + rebuild; no canvas churn
|
||
});
|
||
lock.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
b.locked = !b.locked;
|
||
if (b.locked) { // a locked box drops out of the selection
|
||
const idx = node._boxes.indexOf(b);
|
||
node._selection.delete(idx);
|
||
if (node._activeIdx === idx) node._activeIdx = node._selection.size ? [...node._selection][0] : -1;
|
||
}
|
||
commit();
|
||
});
|
||
row.addEventListener("click", (ev) => {
|
||
if (node._exSuppressClick) { node._exSuppressClick = false; return; } // swallow the click that trails a reparent drag
|
||
canvasEl.focus(); // show the selection on canvas + route Del/Ctrl+C here
|
||
const idx = node._boxes.indexOf(b);
|
||
if (ev.shiftKey && node._selAnchor != null && node._selAnchor !== idx) {
|
||
// Shift = range-select from the anchor to here, in the CURRENT displayed (tree) order.
|
||
const order = Array.from(exList.querySelectorAll(".ai2go-ideo-lrow")).map((r) => node._boxes.indexOf(r._box));
|
||
const a = order.indexOf(node._selAnchor), c = order.indexOf(idx);
|
||
if (a >= 0 && c >= 0) {
|
||
const [lo, hi] = a < c ? [a, c] : [c, a];
|
||
node._selection = new Set(order.slice(lo, hi + 1).filter((k) => k >= 0));
|
||
node._activeIdx = idx;
|
||
} else { selectOnly(idx); node._selAnchor = idx; }
|
||
} else if (ev.ctrlKey || ev.metaKey) {
|
||
// Ctrl/Cmd = toggle this row in/out of the multi-selection.
|
||
if (node._selection.has(idx) && node._selection.size > 1) {
|
||
node._selection.delete(idx);
|
||
if (node._activeIdx === idx) node._activeIdx = node._selection.values().next().value ?? -1;
|
||
} else {
|
||
node._selection.add(idx); node._activeIdx = idx;
|
||
}
|
||
node._selAnchor = idx;
|
||
} else {
|
||
selectOnly(idx); node._selAnchor = idx; // plain click → single select + set the range anchor
|
||
}
|
||
commit();
|
||
});
|
||
row.addEventListener("dblclick", (e) => { // rename a group by double-clicking its row
|
||
e.stopPropagation();
|
||
if (!b.group) return;
|
||
const nm = (window.prompt("Group name:", b.name || "Group") || "").trim();
|
||
if (nm) { b.name = nm; commit(); }
|
||
});
|
||
dup.addEventListener("click", (e) => { e.stopPropagation(); startPlacing(node._boxes.indexOf(b)); });
|
||
del.addEventListener("click", (e) => {
|
||
e.stopPropagation();
|
||
const idx = node._boxes.indexOf(b);
|
||
if (idx < 0) return;
|
||
removeBox(idx); commit(); fitCanvas();
|
||
});
|
||
// Drag a row to RE-PARENT: drop ONTO another row → become its child; drop on the "Overview"
|
||
// header → back to root. If the dragged row is part of a multi-selection, the WHOLE selection is
|
||
// reparented (so you can add many regions to a group at once); only its top-level boxes move, so
|
||
// any nesting inside the selection is preserved. Cycle-guarded (can't drop onto a dragged box or
|
||
// a descendant of one).
|
||
row.addEventListener("pointerdown", (e) => {
|
||
if (e.button !== 0 || e.target === lock || e.target === dup || e.target === del || e.target === tri || e.target === enableBtn || b.locked) return; // locked = frozen, no reparent
|
||
e.preventDefault(); e.stopPropagation();
|
||
const sx = e.clientX, sy = e.clientY;
|
||
const myIdx = node._boxes.indexOf(b);
|
||
const draggedIds = (node._selection.has(myIdx) ? [...node._selection].map((i) => node._boxes[i]?.id) : [b.id]).filter((id) => { const bx = boxById(id); return bx && !bx.locked; }); // never reparent locked boxes
|
||
const draggedSet = new Set(draggedIds);
|
||
let dragging = false, targetRow = null, toRoot = false, pill = null;
|
||
const clearCue = () => {
|
||
for (const r of exList.querySelectorAll(".ai2go-ideo-lrow.drop-into")) r.classList.remove("drop-into");
|
||
exHead.classList.remove("drop-root");
|
||
};
|
||
const move = (me) => {
|
||
if (!dragging) {
|
||
if (Math.abs(me.clientX - sx) + Math.abs(me.clientY - sy) < 4) return;
|
||
dragging = true; document.body.classList.add("ai2go-ideo-dragging");
|
||
for (const r of exList.querySelectorAll(".ai2go-ideo-lrow")) if (draggedSet.has(r._box?.id)) r.classList.add("dragging"); // fade the whole dragged set
|
||
pill = document.createElement("div"); pill.className = "ai2go-ideo-dragpill"; document.body.appendChild(pill); // cursor-following drag label
|
||
}
|
||
clearCue(); targetRow = null; toRoot = false;
|
||
const hr = exHead.getBoundingClientRect();
|
||
if (me.clientX >= hr.left && me.clientX <= hr.right && me.clientY >= hr.top && me.clientY <= hr.bottom) {
|
||
toRoot = true; exHead.classList.add("drop-root"); return; // header zone → unparent
|
||
}
|
||
for (const other of exList.querySelectorAll(".ai2go-ideo-lrow")) {
|
||
const tb = other._box;
|
||
if (!tb || draggedSet.has(tb.id)) continue; // can't drop onto a dragged row
|
||
const r = other.getBoundingClientRect();
|
||
if (me.clientY >= r.top && me.clientY <= r.bottom) {
|
||
const wouldMove = draggedIds.some((did) => { const bx = boxById(did); return bx && !(bx.parent != null && draggedSet.has(bx.parent)) && (bx.parent ?? null) !== tb.id; });
|
||
if (wouldMove && !draggedIds.some((did) => isAncestor(did, tb.id))) { targetRow = other; other.classList.add("drop-into"); } // cue only when it changes something; no cycles
|
||
break;
|
||
}
|
||
}
|
||
if (pill) { // live drag label following the cursor
|
||
pill.style.left = (me.clientX + 14) + "px"; pill.style.top = (me.clientY + 16) + "px";
|
||
const what = draggedIds.length > 1 ? draggedIds.length + " regions" : "1 region";
|
||
const dest = toRoot ? " → root" : (targetRow ? " → " + (targetRow._box.group ? ("📁 " + (targetRow._box.name || "Group")) : (rowLabel(targetRow._box) || "region")) : "");
|
||
pill.textContent = what + dest;
|
||
}
|
||
};
|
||
const cleanup = () => {
|
||
document.removeEventListener("pointermove", move);
|
||
document.removeEventListener("pointerup", up);
|
||
document.removeEventListener("pointercancel", up);
|
||
document.body.classList.remove("ai2go-ideo-dragging");
|
||
for (const r of exList.querySelectorAll(".ai2go-ideo-lrow.dragging")) r.classList.remove("dragging"); // clear the dragged-set fade
|
||
if (pill) { pill.remove(); pill = null; }
|
||
node._exDragCleanup = null;
|
||
};
|
||
const up = () => {
|
||
cleanup(); clearCue();
|
||
if (!dragging) return;
|
||
row.classList.remove("dragging");
|
||
// commit() below rebuilds the tree (destroying this row), so suppress the trailing click.
|
||
const targetId = toRoot ? null : (targetRow ? targetRow._box.id : undefined);
|
||
if (targetId === undefined) return; // dropped on nothing → let the click through
|
||
// Reparent only the top-level dragged boxes (those whose parent isn't itself dragged).
|
||
let changed = false;
|
||
for (const did of draggedIds) {
|
||
const bx = boxById(did);
|
||
if (bx && !(bx.parent != null && draggedSet.has(bx.parent))) { if (setParent(did, targetId)) changed = true; }
|
||
}
|
||
if (changed) { // commit() rebuilds the tree → suppress the trailing click
|
||
node._exSuppressClick = true; setTimeout(() => { node._exSuppressClick = false; }, 0);
|
||
node._selection = new Set(draggedIds.map((id) => node._boxes.findIndex((x) => x.id === id)).filter((i) => i >= 0));
|
||
node._activeIdx = node._boxes.findIndex((x) => x.id === b.id);
|
||
node._selAnchor = node._activeIdx; // keep the Shift-range anchor consistent
|
||
commit();
|
||
}
|
||
};
|
||
document.addEventListener("pointermove", move);
|
||
document.addEventListener("pointerup", up);
|
||
document.addEventListener("pointercancel", up);
|
||
node._exDragCleanup?.(); node._exDragCleanup = cleanup; // clear any stale drag first; onRemoved drops these if the node dies mid-drag
|
||
});
|
||
|
||
if (hasKids && !b.collapsed) for (const child of kids.get(b.id)) mkRow(child, depth + 1);
|
||
};
|
||
for (const b of (kids.get("__root__") || [])) mkRow(b, 0);
|
||
|
||
// Scroll the active row into view only when it's actually off-screen (commit() runs on every
|
||
// canvas edit, so an unconditional scrollIntoView would churn the dock/page scroll).
|
||
const activeRow = exList.querySelector(".ai2go-ideo-lrow.active");
|
||
if (activeRow && !node.properties.explorerCollapsed) {
|
||
const lr = exList.getBoundingClientRect(), ar = activeRow.getBoundingClientRect();
|
||
if (ar.top < lr.top || ar.bottom > lr.bottom) activeRow.scrollIntoView({ block: "nearest" });
|
||
}
|
||
}
|
||
|
||
// ── width/height widget callbacks ──
|
||
for (const w of [wWidget, hWidget]) {
|
||
if (!w) continue;
|
||
chainCallback(w, "callback", () => { syncCanvasToDims(); drawCanvas(); fitCanvas(); updateTokens(); }); // absolute coords depend on w/h
|
||
}
|
||
// Update the token estimate when the caption-level text widgets change.
|
||
for (const name of ["background", "high_level_description", "aesthetics", "lighting", "medium", "style"]) {
|
||
const w = findW(name);
|
||
if (w) chainCallback(w, "callback", () => updateTokens());
|
||
}
|
||
|
||
// Persist node resizes: the 2.0 resize drag preventDefaults the compat mouseup, so the change
|
||
// tracker (snapshots on window 'mouseup') misses the new size — nudge it once the resize settles.
|
||
let _sizeFlush = 0;
|
||
chainCallback(node, "onResize", function () {
|
||
clearTimeout(_sizeFlush);
|
||
_sizeFlush = setTimeout(flushChange, 150);
|
||
});
|
||
|
||
// Optional reference image as the canvas background (matches ImageTransformKJ).
|
||
function loadBg(src, keepAspect) {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.onload = () => {
|
||
node._bgImg = img;
|
||
if (!keepAspect) { // live mode keeps the user's canvas aspect
|
||
const r16 = (v) => Math.max(16, Math.round(v / 16) * 16); // model needs multiples of 16
|
||
if (wWidget) wWidget.value = r16(img.naturalWidth); // match canvas aspect to the image
|
||
if (hWidget) hWidget.value = r16(img.naturalHeight);
|
||
}
|
||
syncCanvasToDims(); drawCanvas(); fitCanvas(); updateGrabBtn();
|
||
};
|
||
img.src = src;
|
||
}
|
||
watchImageInputs(node, "image", (sources) => {
|
||
if (!sources.length) { if (!node._bgManual) { node._bgImg = null; drawCanvas(); updateGrabBtn(); } return; }
|
||
node._bgManual = false; // a connected image takes over
|
||
const s = sources[0];
|
||
if (s.isVideo && s.videoEl) captureVideoFrame(s.videoEl, (cv) => loadBg(cv.toDataURL("image/webp", 0.9)));
|
||
else if (s.url) loadBg(s.url);
|
||
});
|
||
// "Grab BG" button: use the last generated image as the background, or clear it.
|
||
node._grabResultBg = () => {
|
||
if (!lastResultImage) { alert("No sampling result yet — run a generation first."); return; }
|
||
node._bgManual = true;
|
||
loadBg(resultViewUrl(lastResultImage));
|
||
};
|
||
node._clearBg = () => {
|
||
node._bgManual = false; node._bgImg = null;
|
||
if (node._liveBmp?.close) { try { node._liveBmp.close(); } catch (e) {} node._liveBmp = null; }
|
||
drawCanvas(); updateGrabBtn();
|
||
};
|
||
// Feed a live sampling-preview frame as the background (no width/height change).
|
||
node._ideoSetLiveBg = (bmp) => {
|
||
if (node._liveBmp?.close && node._liveBmp !== bmp) { try { node._liveBmp.close(); } catch (e) {} }
|
||
node._liveBmp = bmp; node._bgImg = bmp; node._bgManual = true;
|
||
drawCanvas(); updateGrabBtn();
|
||
};
|
||
// After generation, replace the live preview with the full-res final result.
|
||
node._ideoGrabFinal = () => {
|
||
if (!lastResultImage) return;
|
||
if (node._liveBmp?.close) { try { node._liveBmp.close(); } catch (e) {} node._liveBmp = null; }
|
||
node._bgManual = true;
|
||
loadBg(resultViewUrl(lastResultImage), true); // keep aspect — live mode must never resize the canvas
|
||
};
|
||
|
||
// Active-box highlight only while the editor is focused or the node is selected.
|
||
wrap.addEventListener("focusin", () => { if (!node._focused) { node._focused = true; drawCanvas(); } });
|
||
wrap.addEventListener("focusout", (e) => {
|
||
if (!wrap.contains(e.relatedTarget)) { node._focused = false; drawCanvas(); }
|
||
});
|
||
chainCallback(node, "onSelected", function () { node._selected = true; drawCanvas(); });
|
||
chainCallback(node, "onDeselected", function () { node._selected = false; finishPlacing(); closeLayersMenu(); drawCanvas(); });
|
||
|
||
chainCallback(node, "onRemoved", function () {
|
||
livePreviewNodes.delete(node);
|
||
node._exDragCleanup?.(); // drop any in-progress explorer drag listeners
|
||
if (hoveredCanvasNode === node) hoveredCanvasNode = null;
|
||
if (node._fullscreen) { // tear down the popup if open
|
||
document.removeEventListener("keydown", onFsEsc, true);
|
||
window.removeEventListener("resize", fitFsCanvas);
|
||
node._fsOverlay?.remove();
|
||
}
|
||
pinnedDocks.delete(node); liveDocks.delete(node);
|
||
node._dockRO?.disconnect(); node._dockEl?.remove(); // tear down the floating dock if open
|
||
node._visObserver?.disconnect();
|
||
if (node._liveBmp?.close) { try { node._liveBmp.close(); } catch (e) {} node._liveBmp = null; } // release GPU bitmap
|
||
closeBgMenu(); node._bgMenu?.remove();
|
||
closeTxtMenu(); node._txtMenu?.remove();
|
||
closeTplMenu(); node._tplMenu?.remove();
|
||
closeOutMenu(); node._outMenu?.remove();
|
||
closeInlineEditor();
|
||
closeLayersMenu();
|
||
closeOverviewMenu();
|
||
for (const ro of node._areaObservers) ro.disconnect();
|
||
node._areaObservers = [];
|
||
});
|
||
|
||
// ── restore on load ──
|
||
function _parseBoxes(s) {
|
||
try {
|
||
const p = JSON.parse(s);
|
||
if (Array.isArray(p) && p.some((b) => b && typeof b.x === "number" && typeof b.w === "number")) return p;
|
||
} catch (e) {}
|
||
return null;
|
||
}
|
||
// Persist editor data by name (robust to widget-order changes across versions).
|
||
chainCallback(node, "onSerialize", function (o) {
|
||
if (!o) return;
|
||
const p = node.properties;
|
||
o.ideo = { boxes: node._boxes, palette: node._stylePalette, importMode: findW("import_mode")?.value,
|
||
outputFormat: findW("output_format")?.value, coordMode: findW("coord_mode")?.value, bboxOrder: findW("bbox_order")?.value,
|
||
dock: { pinned: p.dockPinned, graph: p.dockGraph, rect: p.dockRect, panelH: node._panelH, min: p.dockMin,
|
||
exposeParent: p.exposeToParent, parent: p.dockParent } };
|
||
});
|
||
chainCallback(node, "onConfigure", function (o) {
|
||
const raw = o && Array.isArray(o.widgets_values) ? o.widgets_values : [];
|
||
// Restore dock geometry from the blob into node.properties BEFORE ensureDocked runs.
|
||
// Use the SAVED size (o.size) — old workflows' tall embedded-editor height — so the dock gets a
|
||
// sensible height. node.size here is already shrunk to content by configure's widget application.
|
||
node._savedSize = (o && Array.isArray(o.size) && o.size.length === 2) ? [o.size[0], o.size[1]] : (node.size ? [node.size[0], node.size[1]] : null);
|
||
const d = o && o.ideo && o.ideo.dock;
|
||
if (d) {
|
||
node._dockGeomRestored = true; // honor it; old workflows w/o the blob get a default
|
||
if (d.graph) node.properties.dockGraph = d.graph;
|
||
if (d.rect) node.properties.dockRect = d.rect;
|
||
if (d.pinned != null) node.properties.dockPinned = d.pinned;
|
||
if (d.min != null) node.properties.dockMin = d.min;
|
||
if (d.exposeParent != null) node.properties.exposeToParent = d.exposeParent;
|
||
if (d.parent) node.properties.dockParent = d.parent;
|
||
if (typeof d.panelH === "number") { node._panelH = d.panelH; panel.style.height = d.panelH + "px"; }
|
||
}
|
||
node._syncExBtn?.(); // refresh the expose-to-parent toggle after restore
|
||
// Recover regions: name-keyed blob → named widget → raw saved values (survives
|
||
// any widget reorder/remap across versions) → live widgets.
|
||
let boxes = (o && o.ideo && Array.isArray(o.ideo.boxes)) ? o.ideo.boxes : _parseBoxes(elementsWidget?.value || "");
|
||
if (!boxes) { for (const v of raw) { const b = _parseBoxes(v); if (b) { boxes = b; break; } } }
|
||
if (!boxes) { for (const w of node.widgets || []) { const b = _parseBoxes(w?.value); if (b) { boxes = b; break; } } }
|
||
if (boxes) {
|
||
node._boxes = boxes.filter((b) => b && typeof b.x === "number");
|
||
selectOnly(node._boxes.length ? 0 : -1);
|
||
}
|
||
const isPal = (p) => Array.isArray(p) && p.length && p.every((c) => typeof c === "string" && c[0] === "#");
|
||
let pal = (o && o.ideo && isPal(o.ideo.palette)) ? o.ideo.palette : null;
|
||
if (!pal) { try { const p = JSON.parse(stylePaletteWidget?.value || ""); if (isPal(p)) pal = p; } catch (e) {} }
|
||
if (!pal) { for (const v of raw) { try { const p = JSON.parse(v); if (isPal(p)) { pal = p; break; } } catch (e) {} } }
|
||
if (pal) node._stylePalette = pal.slice();
|
||
const imW = findW("import_mode"); // restore import_mode; coerce to a valid option so
|
||
if (imW) { // old workflows (saved before this widget) don't fail Combo validation
|
||
const im = o && o.ideo && o.ideo.importMode, opts = ["when empty", "always"];
|
||
imW.value = opts.includes(im) ? im : (opts.includes(imW.value) ? imW.value : "when empty");
|
||
}
|
||
if (formatWidget) { // restore output format from the blob; default compact unless explicitly pretty
|
||
formatWidget.value = (o && o.ideo && o.ideo.outputFormat) === "pretty" ? "pretty" : "compact";
|
||
compactLbl._cb.checked = formatWidget.value === "compact";
|
||
}
|
||
if (coordWidget) { // restore coord mode; default normalized unless explicitly absolute
|
||
coordWidget.value = (o && o.ideo && o.ideo.coordMode) === "absolute" ? "absolute" : "normalized";
|
||
absLbl._cb.checked = coordWidget.value === "absolute";
|
||
}
|
||
if (orderWidget) { // restore bbox axis order; default yx unless explicitly xy
|
||
orderWidget.value = (o && o.ideo && o.ideo.bboxOrder) === "xy" ? "xy" : "yx";
|
||
xyLbl._cb.checked = orderWidget.value === "xy";
|
||
}
|
||
node._configured = true; // mark as loaded so initial layout keeps the restored size
|
||
hideDataWidgets();
|
||
serialize(); // realign widget values for Python + future saves
|
||
if (bgBrightnessWidget) {
|
||
if (typeof bgBrightnessWidget.value !== "number") bgBrightnessWidget.value = 25; // old workflows may restore ""
|
||
bgSlider.value = bgBrightnessWidget.value;
|
||
}
|
||
// node.properties is restored after onNodeCreated, so resync the toolbar controls to it.
|
||
liveChk.checked = !!node.properties.liveBg;
|
||
if (liveChk.checked) livePreviewNodes.add(node); else livePreviewNodes.delete(node);
|
||
guideSel.value = node.properties.guide || "none";
|
||
gridSlider.value = GRID_INV - (node.properties.gridSize || 10);
|
||
snapChk.checked = !!node.properties.snap;
|
||
guideColor.value = node.properties.guideColor || "#ffffff";
|
||
opacitySlider.value = node.properties.guideOpacity == null ? 100 : node.properties.guideOpacity;
|
||
showLbl._cb.checked = node.properties.showBoxText !== false;
|
||
syncGroupsBtn();
|
||
strokeLbl._cb.checked = node.properties.textStroke !== false;
|
||
autoLbl._cb.checked = node.properties.textAutoPlace !== false;
|
||
sizeSlider.value = node.properties.textSize || 12;
|
||
boxOpacSlider.value = node.properties.boxOpacity == null ? 14 : node.properties.boxOpacity;
|
||
if (node.properties.explorerW) explorerEl.style.width = node.properties.explorerW + "px";
|
||
applyExplorerCollapsed(!!node.properties.explorerCollapsed);
|
||
syncCanvasToDims();
|
||
rebuildStylePalette();
|
||
renderPanel();
|
||
renderExplorer();
|
||
drawCanvas();
|
||
updateTokens();
|
||
// configure() shrinks the node to content height (multiline widgets) before this runs — for a
|
||
// current-version save, re-assert the saved widgets-only size. Skip for old (pre-dock) workflows:
|
||
// their saved height included the embedded editor, so let those shrink to content instead.
|
||
if (node._dockGeomRestored && o && Array.isArray(o.size) && o.size.length === 2) node.setSize([o.size[0], o.size[1]]);
|
||
requestAnimationFrame(() => ensureDocked(false)); // reload: build the dock honoring saved geometry
|
||
});
|
||
|
||
// Initial layout (deferred so size/last_y settle). NEVER set height here — it must stay whatever
|
||
// ComfyUI restored (touching it was the "height resets on reload/copy-paste" bug); only nudge a default width.
|
||
setTimeout(() => {
|
||
hideDataWidgets();
|
||
if (!node._configured) node.setSize([Math.max(480, node.size[0]), node.size[1]]); // fresh: comfortable default width, keep natural height
|
||
else if (!node._dockGeomRestored) node.setSize([Math.max(380, node.size[0]), node.computeSize()[1]]); // old workflow: drop the embedded-editor height, shrink to content
|
||
else if (node.size[0] < 380) node.setSize([380, node.size[1]]); // current reload/paste: keep saved size, enforce min width
|
||
syncCanvasToDims();
|
||
rebuildStylePalette();
|
||
renderPanel();
|
||
renderExplorer();
|
||
drawCanvas();
|
||
updateTokens();
|
||
if (!node._configured) ensureDocked(true); // fresh node: pop the dock under it, matching its width
|
||
}, 0);
|
||
});
|
||
},
|
||
});
|