2565 lines
135 KiB
JavaScript
2565 lines
135 KiB
JavaScript
import { chainCallback, addMiddleClickPan, addWheelPassthrough, cursorForBboxMode, watchImageInputs, captureVideoFrame } 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
|
||
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 {
|
||
app.api?.addEventListener?.("executed", (e) => {
|
||
const imgs = e?.detail?.output?.images;
|
||
if (Array.isArray(imgs) && imgs.length) {
|
||
lastResultImage = imgs[imgs.length - 1];
|
||
// Live nodes swap their preview for the full-res final result automatically.
|
||
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();
|
||
try {
|
||
app.api?.addEventListener?.("b_preview", (e) => {
|
||
const blob = e?.detail;
|
||
if (!blob || !livePreviewNodes.size) return;
|
||
createImageBitmap(blob).then((bmp) => {
|
||
let used = false;
|
||
for (const n of livePreviewNodes) { n._ideoSetLiveBg?.(bmp); used = true; }
|
||
if (!used && bmp.close) bmp.close();
|
||
}).catch(() => {});
|
||
});
|
||
} 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 = "kjnodes/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) return;
|
||
|
||
const key = e.key.toLowerCase();
|
||
if (!["h", "b", "и", "s", "і", "ы", "[", "]", "х", "ї", "ъ"].includes(key)) return;
|
||
|
||
const ae = document.activeElement;
|
||
if (ae && (ae.tagName === "TEXTAREA" || ae.tagName === "INPUT" || ae.isContentEditable)) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (key === "h") hoveredCanvasNode._toggleHideBoxes?.();
|
||
if (key === "b" || key === "и") hoveredCanvasNode._toggleBrushMode?.();
|
||
if (key === "s" || key === "і" || key === "ы") hoveredCanvasNode._toggleSketchMode?.();
|
||
if (hoveredCanvasNode._isBrushMode) {
|
||
if (key === "[" || key === "х") {
|
||
hoveredCanvasNode._brushSize = Math.max(2, hoveredCanvasNode._brushSize - 5);
|
||
if (hoveredCanvasNode._syncBrush) hoveredCanvasNode._syncBrush();
|
||
} else if (key === "]" || key === "ї" || key === "ъ") {
|
||
hoveredCanvasNode._brushSize += 5;
|
||
if (hoveredCanvasNode._syncBrush) hoveredCanvasNode._syncBrush();
|
||
}
|
||
}
|
||
if (hoveredCanvasNode._isSketchMode) {
|
||
if (key === "[" || key === "х") {
|
||
if(hoveredCanvasNode._syncSkSize) hoveredCanvasNode._syncSkSize(Math.max(1, hoveredCanvasNode._sketchSize - 5));
|
||
} else if (key === "]" || key === "ї" || key === "ъ") {
|
||
if(hoveredCanvasNode._syncSkSize) hoveredCanvasNode._syncSkSize(hoveredCanvasNode._sketchSize + 5);
|
||
}
|
||
}
|
||
}, true);
|
||
|
||
function injectStyle() {
|
||
if (document.getElementById("kora-ideo-style")) return;
|
||
const s = document.createElement("style");
|
||
s.id = "kora-ideo-style";
|
||
s.textContent = `
|
||
.kora-wrap { display:flex; flex-direction:column; overflow:hidden; position:relative; pointer-events:auto; gap:4px; }
|
||
.kora-canvas { cursor:crosshair; display:block; width:100%; height:auto; flex:0 0 auto; background:#1a1a1a; border-radius:4px; outline:none; touch-action:none; }
|
||
.kora-bar { display:flex; align-items:center; gap:6px; font:11px sans-serif; color:#aaa; user-select:none; padding:0 2px; flex:0 0 auto; }
|
||
.kora-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; }
|
||
.kora-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
||
.kora-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; }
|
||
.kora-btn:hover { border-color:#46b4e6; color:#fff; }
|
||
.kora-btn.active { border-color:#46b4e6; color:#46b4e6; background:#2a3a42; }
|
||
.kora-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:vertical; min-height:36px; }
|
||
.kora-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; }
|
||
.kora-sw:hover { transform:scale(1.2); box-shadow:0 0 0 2px #46b4e6; z-index:3; }
|
||
.kora-sw.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; }
|
||
body.kora-dragging, body.kora-dragging * { cursor:move !important; }
|
||
.kora-sw input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
|
||
.kora-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; }
|
||
.kora-bbox { width:128px; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#bbb; font:11px monospace; padding:2px 5px; }
|
||
.kora-bbox:focus { border-color:#46b4e6; outline:none; color:#fff; }
|
||
.kora-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; }
|
||
.kora-mhdr { font:11px sans-serif; color:#888; padding:2px 6px 4px; user-select:none; }
|
||
.kora-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; }
|
||
.kora-lrow:hover { background:#333; }
|
||
.kora-lrow.active { background:#2a3a42; box-shadow:inset 0 0 0 1px #46b4e6; }
|
||
.kora-lrow.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; background:#333; }
|
||
.kora-lsw { width:16px; height:16px; border-radius:3px; border:1px solid #666; flex:0 0 auto; }
|
||
.kora-lnum { font:bold 11px monospace; color:#888; flex:0 0 auto; width:18px; }
|
||
.kora-ltext { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.kora-ltext.empty { color:#777; font-style:italic; }
|
||
.kora-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; }
|
||
.kora-lbtn:hover { color:#fff; background:#444; }
|
||
.kora-lbtn.del:hover { color:#fff; background:#a33; }
|
||
.kora-fs { position:fixed; inset:0; z-index:9000; background:rgba(0,0,0,0.72); display:flex; align-items:center; justify-content:center; }
|
||
.kora-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; }
|
||
.kora-fs-inner .kora-wrap { height:100%; }
|
||
.kora-bgmenu { padding:7px; display:flex; flex-direction:column; gap:7px; min-width:170px; }
|
||
.kora-bgrow { display:flex; align-items:center; gap:8px; }
|
||
.kora-bglbl { color:#888; font:11px sans-serif; flex:0 0 auto; min-width:62px; }
|
||
.kora-trow { padding:2px 4px; border-radius:4px; }
|
||
.kora-trow:hover { background:#333; }
|
||
`;
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
app.registerExtension({
|
||
name: "Kora.IdeogramMaskBuilder",
|
||
|
||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||
// Kora
|
||
if (nodeData?.name !== "Ideogram_mask_build_Kora") 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");
|
||
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() {
|
||
const ew = findW("elements_data"), spw = findW("style_palette_data"), bbw = findW("bg_brightness"), bboxesW = findW("bboxes"), skW = findW("sketch_data");
|
||
for (const w of [ew, spw, bbw, bboxesW, skW]) {
|
||
if (!w) continue;
|
||
w.hidden = true;
|
||
w.computeSize = () => [0, -4];
|
||
}
|
||
for (const name of ["elements_data", "style_palette_data", "bg_brightness"]) {
|
||
const i = node.inputs?.findIndex((inp) => inp.name === name);
|
||
if (i != null && i !== -1) node.removeInput(i);
|
||
}
|
||
}
|
||
hideDataWidgets();
|
||
node._boxes = [];
|
||
node._brushSize = 20;
|
||
node._brushBlur = 5;
|
||
node._maskPaths = [];
|
||
node._currentSessionPaths = []; // KOra
|
||
node._isBrushMode = false;
|
||
node._isDrawingMask = false; // {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._areaH = node._areaH || {}; // remembered textarea heights (per field)
|
||
node._areaObservers = []; // live ResizeObservers to disconnect on rebuild
|
||
|
||
const cursorDiv = document.createElement("div");
|
||
cursorDiv.style.position = "fixed";
|
||
cursorDiv.style.pointerEvents = "none";
|
||
cursorDiv.style.borderRadius = "50%";
|
||
cursorDiv.style.border = "1.5px solid rgba(255,255,255,0.9)";
|
||
cursorDiv.style.boxShadow = "0 0 3px rgba(0,0,0,0.5)";
|
||
cursorDiv.style.transform = "translate(-50%, -50%)";
|
||
cursorDiv.style.zIndex = "10000";
|
||
cursorDiv.style.display = "none";
|
||
document.body.appendChild(cursorDiv);
|
||
node._cursorDiv = cursorDiv;
|
||
|
||
// ── DOM ──
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "kora-wrap";
|
||
const bar = document.createElement("div");
|
||
bar.className = "kora-bar";
|
||
const hint = document.createElement("span");
|
||
hint.style.flex = "1";
|
||
const copyBtn = document.createElement("button");
|
||
copyBtn.className = "kora-btn";
|
||
copyBtn.textContent = "Copy";
|
||
copyBtn.title = "Copy the current caption JSON to the clipboard";
|
||
const importBtn = document.createElement("button");
|
||
importBtn.className = "kora-btn";
|
||
importBtn.textContent = "Paste";
|
||
importBtn.title = "Parse a caption JSON (clipboard, else paste prompt) and populate the node";
|
||
const clearBtn = document.createElement("button");
|
||
clearBtn.className = "kora-btn";
|
||
clearBtn.textContent = "Clear all";
|
||
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)";
|
||
const grabBtn = document.createElement("button");
|
||
grabBtn.className = "kora-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 = "kora-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(); });
|
||
const fsBtn = document.createElement("button");
|
||
fsBtn.className = "kora-btn"; fsBtn.textContent = "⛶";
|
||
fsBtn.title = "Open in a larger window (Esc to close)";
|
||
stopProp(fsBtn);
|
||
fsBtn.addEventListener("click", () => node._fullscreen ? exitFs() : enterFs());
|
||
// Group the background/guide controls into one popup to keep the toolbar tidy.
|
||
const bgBtn = document.createElement("button");
|
||
bgBtn.className = "kora-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 = "kora-menu kora-bgmenu";
|
||
bgMenu.style.display = "none";
|
||
const bgRow = (labelText, el) => {
|
||
const r = document.createElement("div"); r.className = "kora-bgrow";
|
||
if (labelText) { const l = document.createElement("span"); l.className = "kora-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;
|
||
function closeBgMenu() {
|
||
bgMenu.style.display = "none";
|
||
if (node._bgMenuDismiss) {
|
||
document.removeEventListener("pointerdown", node._bgMenuDismiss, true);
|
||
document.removeEventListener("mousedown", node._bgMenuDismiss, true);
|
||
node._bgMenuDismiss = null;
|
||
}
|
||
}
|
||
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";
|
||
node._bgMenuDismiss = (e) => { if (!bgMenu.contains(e.target) && e.target !== bgBtn) closeBgMenu(); };
|
||
setTimeout(() => {
|
||
document.addEventListener("pointerdown", node._bgMenuDismiss, true);
|
||
document.addEventListener("mousedown", node._bgMenuDismiss, true);
|
||
}, 0);
|
||
});
|
||
// ── Templates popup: save/load named caption JSONs (server-side userdata) ──
|
||
const tplBtn = document.createElement("button");
|
||
tplBtn.className = "kora-btn"; tplBtn.textContent = "Templates ▾";
|
||
tplBtn.title = "Save / load the caption JSON as templates (stored on the server ComfyUI\\user\\default\\kjnodes\\ideogram4)";
|
||
stopProp(tplBtn);
|
||
const tplMenu = document.createElement("div");
|
||
tplMenu.className = "kora-menu kora-bgmenu";
|
||
tplMenu.style.display = "none";
|
||
document.body.appendChild(tplMenu);
|
||
node._tplMenu = tplMenu;
|
||
function closeTplMenu() {
|
||
tplMenu.style.display = "none";
|
||
if (node._tplDismiss) {
|
||
document.removeEventListener("pointerdown", node._tplDismiss, true);
|
||
document.removeEventListener("mousedown", node._tplDismiss, true);
|
||
node._tplDismiss = null;
|
||
}
|
||
}
|
||
async function buildTplMenu() {
|
||
tplMenu.innerHTML = "";
|
||
const saveRow = document.createElement("div"); saveRow.className = "kora-bgrow";
|
||
const saveBtn = document.createElement("button"); saveBtn.className = "kora-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, buildCaption())) buildTplMenu();
|
||
});
|
||
saveRow.appendChild(saveBtn); tplMenu.appendChild(saveRow);
|
||
const names = await listTemplateNames();
|
||
if (!names.length) {
|
||
const empty = document.createElement("div"); empty.className = "kora-mhdr"; empty.textContent = "No templates saved.";
|
||
tplMenu.appendChild(empty);
|
||
}
|
||
for (const name of names) {
|
||
const row = document.createElement("div"); row.className = "kora-bgrow kora-trow";
|
||
const txt = document.createElement("span");
|
||
txt.className = "kora-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 = "kora-lbtn"; ins.textContent = "⊞"; ins.title = "Insert this template's boxes only into the current canvas";
|
||
const upd = document.createElement("button"); upd.className = "kora-lbtn"; upd.textContent = "⤓"; upd.title = "Save current (overwrite)";
|
||
const del = document.createElement("button"); del.className = "kora-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, buildCaption())) 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";
|
||
node._tplDismiss = (e) => { if (!tplMenu.contains(e.target) && e.target !== tplBtn) closeTplMenu(); };
|
||
setTimeout(() => {
|
||
document.addEventListener("pointerdown", node._tplDismiss, true);
|
||
document.addEventListener("mousedown", node._tplDismiss, true);
|
||
}, 0);
|
||
});
|
||
|
||
bar.appendChild(hint); bar.appendChild(tokenSpan); bar.appendChild(bgBtn); bar.appendChild(copyBtn); bar.appendChild(importBtn); bar.appendChild(tplBtn); bar.appendChild(fsBtn); bar.appendChild(clearBtn);
|
||
|
||
// Persistent global style-palette row
|
||
const styleBar = document.createElement("div");
|
||
styleBar.className = "kora-bar";
|
||
const styleLbl = document.createElement("span");
|
||
styleLbl.textContent = "Style colors:";
|
||
styleBar.appendChild(styleLbl);
|
||
|
||
const canvasEl = document.createElement("canvas");
|
||
canvasEl.className = "kora-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);
|
||
addMiddleClickPan(canvasEl);
|
||
|
||
const panel = document.createElement("div");
|
||
panel.className = "kora-panel";
|
||
|
||
// Canvas above panel so the panel grows downward without shifting the canvas.
|
||
wrap.appendChild(bar); wrap.appendChild(styleBar); wrap.appendChild(canvasEl); wrap.appendChild(panel);
|
||
|
||
// --- KORA SKETCH UI ---
|
||
node._sketchPaths = []; node._sketchHistory = [];
|
||
node._isSketchMode = false; node._sketchColor = "#ff0000";
|
||
node._sketchSize = 20; node._sketchBlur = 0;
|
||
|
||
const sketchBar = document.createElement("div"); sketchBar.className = "kora-bar"; sketchBar.style.marginTop = "4px";
|
||
const sketchBtn = document.createElement("button"); sketchBtn.className = "kora-btn"; sketchBtn.textContent = "Add sketch (S)✍️";
|
||
const sketchTools = document.createElement("div"); sketchTools.className = "kora-row"; sketchTools.style.display = "none";
|
||
|
||
const skColor = document.createElement("input"); skColor.type = "color"; skColor.value = node._sketchColor;
|
||
skColor.style.cssText = "width:32px;height:20px;padding:0;cursor:pointer;";
|
||
skColor.title = " Alt + Click"
|
||
skColor.addEventListener("input", () => { node._sketchColor = skColor.value; updateCursorVisuals(); });
|
||
node._syncSkColor = (hex) => { node._sketchColor = hex; skColor.value = hex; };
|
||
|
||
// KORA:
|
||
function addSkScrub(el, field, maxV) {
|
||
let isScr = false, sX = 0, sV = 0;
|
||
const onMv = (e) => {
|
||
if (!isScr) return;
|
||
el.value = Math.max(0, Math.min(maxV, sV + Math.round((e.clientX - sX) / 2)));
|
||
node[field] = parseInt(el.value, 10) || 0; updateCursorVisuals();
|
||
};
|
||
const onUp = () => { if (isScr) { isScr = false; window.removeEventListener("pointermove", onMv); window.removeEventListener("pointerup", onUp); } };
|
||
el.addEventListener("pointerdown", (e) => { isScr = true; sX = e.clientX; sV = parseInt(el.value, 10) || 0; el.focus(); window.addEventListener("pointermove", onMv); window.addEventListener("pointerup", onUp); });
|
||
el.addEventListener("input", () => { node[field] = parseInt(el.value, 10) || 0; updateCursorVisuals(); });
|
||
}
|
||
|
||
const skSizeLbl = document.createElement("span"); skSizeLbl.textContent = "size:"; skSizeLbl.style.color = "#888";
|
||
const skSize = document.createElement("input"); skSize.type = "number"; skSize.value = node._sketchSize;
|
||
skSize.style.cssText = "width:50px; background:#111; color:#fff; border:1px solid #777; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
node._syncSkSize = (val) => { node._sketchSize = val; skSize.value = val; updateCursorVisuals(); };
|
||
addSkScrub(skSize, "_sketchSize", 600);
|
||
|
||
const skBlurLbl = document.createElement("span"); skBlurLbl.textContent = "blur:"; skBlurLbl.style.color = "#888";
|
||
const skBlur = document.createElement("input"); skBlur.type = "number"; skBlur.value = node._sketchBlur;
|
||
skBlur.style.cssText = "width:50px; background:#111; color:#fff; border:1px solid #777; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
addSkScrub(skBlur, "_sketchBlur", 50);
|
||
|
||
node._sketchOpacity = node._sketchOpacity || 100;
|
||
const skOpacLbl = document.createElement("span"); skOpacLbl.textContent = "opac:"; skOpacLbl.style.color = "#888";
|
||
const skOpac = document.createElement("input"); skOpac.type = "number"; skOpac.min = "0"; skOpac.max = "100"; skOpac.value = node._sketchOpacity;
|
||
skOpac.style.cssText = "width:50px; background:#111; color:#fff; border:1px solid #777; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
addSkScrub(skOpac, "_sketchOpacity", 100);
|
||
|
||
const skUndo = document.createElement("button"); skUndo.className = "kora-btn"; skUndo.textContent = "↶";
|
||
skUndo.addEventListener("click", () => { if(node._sketchPaths.length > 0) { node._sketchHistory.push(node._sketchPaths.pop()); commit(); } });
|
||
const skRedo = document.createElement("button"); skRedo.className = "kora-btn"; skRedo.textContent = "↷";
|
||
skRedo.addEventListener("click", () => { if(node._sketchHistory.length > 0) { node._sketchPaths.push(node._sketchHistory.pop()); commit(); } });
|
||
const skReset = document.createElement("button"); skReset.className = "kora-btn"; skReset.textContent = "Reset";
|
||
skReset.addEventListener("click", () => { node._sketchPaths = []; node._sketchHistory = []; commit(); });
|
||
|
||
sketchTools.append(skColor, skSizeLbl, skSize, skBlurLbl, skBlur, skOpacLbl, skOpac, skUndo, skRedo, skReset);
|
||
sketchBar.append(sketchBtn, sketchTools);
|
||
|
||
node._toggleSketchMode = () => {
|
||
node._isSketchMode = !node._isSketchMode;
|
||
sketchBtn.style.cssText = node._isSketchMode ? "background-color: #ef4444; color: white; box-shadow: 0 0 10px #ef4444;" : "";
|
||
sketchTools.style.display = node._isSketchMode ? "flex" : "none";
|
||
canvasEl.style.cursor = node._isSketchMode ? "none" : "crosshair";
|
||
if (node._isSketchMode && node._isBrushMode) node._toggleBrushMode();
|
||
if (!node._isSketchMode && node._cursorDiv) node._cursorDiv.style.display = "none";
|
||
};
|
||
sketchBtn.addEventListener("click", () => node._toggleSketchMode());
|
||
wrap.insertBefore(sketchBar, canvasEl);
|
||
|
||
const TOOLBAR_H = 22;
|
||
node._widgetHeight = 360;
|
||
node.ideoEditor = node.addDOMWidget("ideo_editor", "Ideogram4Editor", wrap, {
|
||
serialize: false, hideOnZoom: false,
|
||
getMinHeight: () => node._widgetHeight,
|
||
});
|
||
node.resizable = true;
|
||
|
||
// DOM widgets are HTML layered over the canvas; ComfyUI only repositions/clips them
|
||
// during a foreground draw. When the node returns from off-screen the element can
|
||
// briefly float over the canvas until the next draw — force one when it re-enters view.
|
||
try {
|
||
node._visObserver = new IntersectionObserver((entries) => {
|
||
if (entries.some((en) => en.isIntersecting)) {
|
||
app.canvas?.setDirtyCanvas?.(true, true);
|
||
drawCanvas();
|
||
}
|
||
});
|
||
node._visObserver.observe(wrap);
|
||
} catch (e) {}
|
||
|
||
// ── canvas sizing ──
|
||
// The display size is CSS-driven (width:100% + aspect-ratio); the backing store
|
||
// is sized to display × devicePixelRatio in _draw() so text/lines stay crisp.
|
||
function setCanvasSize(w, h) {
|
||
canvasEl.style.aspectRatio = `${w} / ${h}`; // display shape only
|
||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||
}
|
||
function syncCanvasToDims() {
|
||
const w = wWidget ? wWidget.value : 1024, h = hWidget ? hWidget.value : 1024;
|
||
setCanvasSize(Math.max(1, w), Math.max(1, h));
|
||
drawCanvas();
|
||
}
|
||
|
||
// Content height = panel's bottom edge in the wrapper (includes toolbar/canvas/gaps).
|
||
function recalcWidgetHeight() {
|
||
const contentH = panel.offsetTop + panel.offsetHeight;
|
||
if (contentH > 0) {
|
||
node._widgetHeight = contentH + 10; // margin pad
|
||
} else { // not laid out yet — estimate
|
||
const ratio = (hWidget?.value || 1) / (wWidget?.value || 1);
|
||
node._widgetHeight = Math.round(Math.max(100, node.size[0] - 30) * ratio) + TOOLBAR_H + 70;
|
||
}
|
||
}
|
||
function fitNode() {
|
||
if (node._fullscreen) { fitFsCanvas(); return; } // in the popup, size the canvas, not the node
|
||
recalcWidgetHeight();
|
||
// computeSize (stable min-heights), not last_y which creeps with growable widgets above.
|
||
const minH = node.computeSize()[1];
|
||
if (node.size[1] < minH) node.setSize([node.size[0], minH]);
|
||
}
|
||
|
||
// ── larger popup window (relocates the editor into a centered overlay) ──
|
||
function fitFsCanvas() { // fit the fixed-aspect canvas into the popup
|
||
if (!node._fullscreen || !node._fsInner) return;
|
||
const availW = node._fsInner.clientWidth - 24;
|
||
const availH = node._fsInner.clientHeight - 24 - 16 - bar.offsetHeight - styleBar.offsetHeight - panel.offsetHeight;
|
||
const aspect = (wWidget?.value || 1) / (hWidget?.value || 1);
|
||
let cw = availW, ch = cw / aspect;
|
||
if (ch > availH) { ch = Math.max(60, availH); cw = ch * aspect; }
|
||
if (cw > availW) { cw = availW; ch = cw / aspect; }
|
||
canvasEl.style.width = Math.round(cw) + "px";
|
||
canvasEl.style.height = Math.round(ch) + "px";
|
||
canvasEl.style.alignSelf = "center";
|
||
drawCanvas();
|
||
}
|
||
function onFsEsc(e) { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); exitFs(); } }
|
||
function enterFs() {
|
||
if (node._fullscreen) return;
|
||
node._fullscreen = true;
|
||
node._wrapHome = wrap.parentNode; // where to return the editor on close
|
||
const ov = document.createElement("div"); ov.className = "kora-fs";
|
||
const inner = document.createElement("div"); inner.className = "kora-fs-inner";
|
||
inner.appendChild(wrap); // move the SAME editor element in (keeps all state)
|
||
ov.appendChild(inner);
|
||
ov.addEventListener("mousedown", (e) => { if (e.target === ov) exitFs(); }); // backdrop closes
|
||
document.body.appendChild(ov);
|
||
node._fsOverlay = ov; node._fsInner = inner;
|
||
if (node.ideoEditor) node.ideoEditor.hidden = true; // stop ComfyUI from reclaiming the element
|
||
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);
|
||
canvasEl.style.width = ""; canvasEl.style.height = ""; canvasEl.style.alignSelf = ""; // restore CSS sizing
|
||
if (node._wrapHome) node._wrapHome.appendChild(wrap);
|
||
node._fsOverlay?.remove(); node._fsOverlay = null; node._fsInner = null;
|
||
if (node.ideoEditor) node.ideoEditor.hidden = false;
|
||
if (node.graph) node.graph.setDirtyCanvas(true, true);
|
||
requestAnimationFrame(() => { fitNode(); drawCanvas(); });
|
||
}
|
||
|
||
// ── 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 rx = HANDLE / logW(), ry = HANDLE / logH();
|
||
const res = [];
|
||
for (let i = 0; i < node._boxes.length; i++) {
|
||
|
||
if (node._isolateActiveBox && i !== node._activeIdx) continue;
|
||
|
||
const b = node._boxes[i];
|
||
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];
|
||
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;
|
||
let pick = [x1, y1];
|
||
for (const [cx, cy] of [[x1, y1], [x2 - w, y1], [x2 - w, y2 - h], [x1, y2 - h]]) {
|
||
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--) {
|
||
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;
|
||
}
|
||
// 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) {
|
||
const pos = cands.findIndex((c) => c.index === node._activeIdx);
|
||
return cands[(pos + 1) % cands.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") {
|
||
return normalizeBox({ ...start, w: w + dx, h: h + dy });
|
||
}
|
||
// 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 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 W = logW(), H = logH(), cell = Math.min(W, H) / gridN();
|
||
const sx = cell / W, sy = cell / H, 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) });
|
||
}
|
||
function updateCursorVisuals(clientX, clientY) {
|
||
if ((node._isBrushMode || node._isSketchMode) && node._cursorDiv) {
|
||
const scale = node._fullscreen ? 1 : (app.canvas?.ds?.scale || 1);
|
||
const size = node._isSketchMode ? node._sketchSize : node._brushSize;
|
||
const blur = node._isSketchMode ? node._sketchBlur : node._brushBlur;
|
||
const screenBrushSize = (size / 1024) * logW() * scale;
|
||
const screenBlur = (blur / 1024) * logW() * scale;
|
||
|
||
node._cursorDiv.style.width = screenBrushSize + "px";
|
||
node._cursorDiv.style.height = screenBrushSize + "px";
|
||
node._cursorDiv.style.boxShadow = `0 0 ${screenBlur * 2}px rgba(255,255,255,0.8)`;
|
||
|
||
// KORA: Фарбуємо курсор у колір скетчу!
|
||
if (node._isSketchMode) node._cursorDiv.style.borderColor = node._sketchColor;
|
||
else node._cursorDiv.style.borderColor = "rgba(255,255,255,0.9)";
|
||
|
||
node._cursorDiv.style.display = "block";
|
||
|
||
if (clientX !== undefined && clientY !== undefined) {
|
||
node._cursorDiv.style.left = clientX + "px";
|
||
node._cursorDiv.style.top = clientY + "px";
|
||
}
|
||
}
|
||
}
|
||
// ── 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") { // square cells (gridSize across the short edge), any aspect
|
||
ctx.strokeStyle = guideStroke(0.24);
|
||
const cell = Math.min(W, H) / gridN();
|
||
for (let px = cell; px < W - 0.5; px += cell) { const X = Math.round(px) + 0.5; ctx.beginPath(); ctx.moveTo(X, 0); ctx.lineTo(X, H); ctx.stroke(); }
|
||
for (let py = cell; py < H - 0.5; py += cell) { const Y = Math.round(py) + 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
|
||
|
||
// --- KORA SKETCH RENDER ON SCREEN ---
|
||
if (node._sketchPaths && node._sketchPaths.length > 0) {
|
||
const skCanvas = document.createElement("canvas");
|
||
skCanvas.width = bw; skCanvas.height = bh;
|
||
const skCtx = skCanvas.getContext("2d");
|
||
skCtx.setTransform(d, 0, 0, d, 0, 0);
|
||
skCtx.lineCap = "round"; skCtx.lineJoin = "round";
|
||
|
||
for (const path of node._sketchPaths) {
|
||
skCtx.strokeStyle = path.color;
|
||
skCtx.lineWidth = (path.size / 1024) * logW();
|
||
skCtx.filter = `blur(${(path.blur / 1024) * logW()}px)`;
|
||
skCtx.globalAlpha = (path.opacity !== undefined ? path.opacity : 100) / 100.0;
|
||
skCtx.beginPath();
|
||
for (let i = 0; i < path.points.length; i++) {
|
||
const p = path.points[i];
|
||
if (i === 0) skCtx.moveTo(p.x * logW(), p.y * logH());
|
||
else skCtx.lineTo(p.x * logW(), p.y * logH());
|
||
}
|
||
if (path.points.length === 1) skCtx.lineTo(path.points[0].x * logW() + 0.1, path.points[0].y * logH() + 0.1);
|
||
skCtx.stroke();
|
||
}
|
||
ctx.save();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
ctx.drawImage(skCanvas, 0, 0, bw, bh);
|
||
ctx.restore();
|
||
}
|
||
|
||
if (node._hideBoxes || node._isSketchMode) 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
|
||
for (const i of order) {
|
||
const active = i === aIdx;
|
||
if (node._isolateActiveBox && !active) continue;
|
||
const b = node._boxes[i], selected = selSet.has(i);
|
||
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 || selected; // selected boxes stay highlighted
|
||
if (selected) { // opaque backing so contents read clearly over boxes behind
|
||
ctx.fillStyle = "rgba(26,26,26,0.28)";
|
||
ctx.fillRect(x1, y1, w, h);
|
||
}
|
||
ctx.fillStyle = col + (hovered ? "3a" : "22"); // tint of the box color
|
||
ctx.fillRect(x1, y1, w, h);
|
||
//
|
||
if (b.maskPaths && b.maskPaths.length > 0) {
|
||
const tmpCanvas = document.createElement("canvas");
|
||
tmpCanvas.width = bw; tmpCanvas.height = bh;
|
||
const tmpCtx = tmpCanvas.getContext("2d");
|
||
tmpCtx.setTransform(d, 0, 0, d, 0, 0);
|
||
tmpCtx.lineCap = "round"; tmpCtx.lineJoin = "round"; tmpCtx.strokeStyle = "red";
|
||
|
||
for (const path of b.maskPaths) {
|
||
tmpCtx.lineWidth = (path.size / 1024) * logW();
|
||
tmpCtx.filter = `blur(${(path.blur / 1024) * logW()}px)`;
|
||
tmpCtx.beginPath();
|
||
for (let i = 0; i < path.points.length; i++) {
|
||
const p = path.points[i];
|
||
if (i === 0) tmpCtx.moveTo(p.x * logW(), p.y * logH());
|
||
else tmpCtx.lineTo(p.x * logW(), p.y * logH());
|
||
}
|
||
if (path.points.length === 1) tmpCtx.lineTo(path.points[0].x * logW() + 0.1, path.points[0].y * logH() + 0.1);
|
||
tmpCtx.stroke();
|
||
}
|
||
|
||
ctx.save();
|
||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||
const screenOp = (b.brush_opacity !== undefined ? b.brush_opacity : 100) / 100.0 * 0.45;
|
||
ctx.globalAlpha = screenOp;
|
||
|
||
ctx.drawImage(tmpCanvas, 0, 0, bw, bh);
|
||
ctx.restore();
|
||
}
|
||
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 (b.mask_enabled && b.mask_opacity > 0) {
|
||
ctx.save();
|
||
const screenOp = (b.mask_opacity / 100.0) * 0.45;
|
||
ctx.fillStyle = `rgba(255, 0, 0, ${screenOp})`;
|
||
if (b.mask_blur > 0) ctx.filter = `blur(${(b.mask_blur / 1024) * logW()}px)`;
|
||
ctx.fillRect(x1, y1, w, h);
|
||
ctx.restore();
|
||
}
|
||
// palette shown as a strip along the top edge
|
||
|
||
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, tag chip on top
|
||
ctx.save();
|
||
//ctx.beginPath(); ctx.rect(x1, y1, w, h); ctx.clip();
|
||
|
||
let body = b.desc || "";
|
||
if (b.type === "text" && b.text) body = `"${b.text}"` + (body ? " — " + body : "");
|
||
if (body && node.properties.showBoxText !== false) { // toggle: show text in boxes
|
||
ctx.font = "12px monospace";
|
||
ctx.fillStyle = readableText(col); // box color, lightened if too dark
|
||
const pad = 4, lh = 14;
|
||
let ty = y1 + 15 + 12; // first line below the tag chip
|
||
for (const line of wrapLines(body, w - pad * 2)) {
|
||
if (ty > y1 + h) break; // clip overflow vertically
|
||
ctx.fillText(line, x1 + pad, ty);
|
||
ty += lh;
|
||
}
|
||
}
|
||
// tag chip on top, at 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) { // hover highlight
|
||
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);
|
||
ctx.restore();
|
||
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([]);
|
||
}
|
||
}
|
||
|
||
|
||
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 ──
|
||
const importConnected = () => !!node.inputs?.some((i) => i.name === "import_json" && i.link != null);
|
||
function exportMaskToBase64() {
|
||
const W = wWidget ? wWidget.value : 1024;
|
||
const H = hWidget ? hWidget.value : 1024;
|
||
const mc = document.createElement("canvas");
|
||
mc.width = W; mc.height = H;
|
||
const mCtx = mc.getContext("2d");
|
||
|
||
mCtx.fillStyle = "black";
|
||
mCtx.fillRect(0, 0, W, H);
|
||
|
||
mCtx.strokeStyle = "white";
|
||
mCtx.lineCap = "round";
|
||
mCtx.lineJoin = "round";
|
||
|
||
for (const path of node._maskPaths) {
|
||
mCtx.lineWidth = (path.size / 1024) * W;
|
||
mCtx.filter = `blur(${(path.blur / 1024) * W}px)`;
|
||
mCtx.beginPath();
|
||
for (let i = 0; i < path.points.length; i++) {
|
||
const p = path.points[i];
|
||
if (i === 0) mCtx.moveTo(p.x * W, p.y * H);
|
||
else mCtx.lineTo(p.x * W, p.y * H);
|
||
}
|
||
if (path.points.length === 1) {
|
||
mCtx.lineTo(path.points[0].x * W + 0.1, path.points[0].y * H + 0.1);
|
||
}
|
||
mCtx.stroke();
|
||
}
|
||
return mc.toDataURL("image/png");
|
||
}
|
||
function serialize() {
|
||
const W = wWidget ? wWidget.value : 1024, H = hWidget ? hWidget.value : 1024;
|
||
const mc = document.createElement("canvas"); mc.width = W; mc.height = H;
|
||
const mCtx = mc.getContext("2d");
|
||
const skW = findW("sketch_data");
|
||
if (skW) {
|
||
if (node._sketchPaths && node._sketchPaths.length > 0) {
|
||
mCtx.clearRect(0, 0, W, H);
|
||
mCtx.save();
|
||
mCtx.lineCap = "round"; mCtx.lineJoin = "round";
|
||
for (const path of node._sketchPaths) {
|
||
mCtx.strokeStyle = path.color;
|
||
mCtx.lineWidth = (path.size / 1024) * W;
|
||
mCtx.filter = `blur(${(path.blur / 1024) * W}px)`;
|
||
mCtx.globalAlpha = (path.opacity !== undefined ? path.opacity : 100) / 100.0;
|
||
mCtx.beginPath();
|
||
for (let i = 0; i < path.points.length; i++) {
|
||
const p = path.points[i];
|
||
if (i === 0) mCtx.moveTo(p.x * W, p.y * H);
|
||
else mCtx.lineTo(p.x * W, p.y * H);
|
||
}
|
||
if (path.points.length === 1) mCtx.lineTo(path.points[0].x * W + 0.1, path.points[0].y * H + 0.1);
|
||
mCtx.stroke();
|
||
}
|
||
mCtx.restore();
|
||
skW.value = mc.toDataURL("image/png");
|
||
} else {
|
||
skW.value = "";
|
||
}
|
||
}
|
||
|
||
for (const b of node._boxes) {
|
||
if (b.maskPaths && b.maskPaths.length > 0) {
|
||
mCtx.clearRect(0, 0, W, H); mCtx.fillStyle = "black"; mCtx.fillRect(0, 0, W, H);
|
||
mCtx.save();
|
||
mCtx.strokeStyle = "white"; mCtx.lineCap = "round"; mCtx.lineJoin = "round";
|
||
for (const path of b.maskPaths) {
|
||
mCtx.lineWidth = (path.size / 1024) * W; mCtx.filter = `blur(${(path.blur / 1024) * W}px)`;
|
||
mCtx.beginPath();
|
||
for (let i = 0; i < path.points.length; i++) {
|
||
const p = path.points[i];
|
||
if (i === 0) mCtx.moveTo(p.x * W, p.y * H); else mCtx.lineTo(p.x * W, p.y * H);
|
||
}
|
||
if (path.points.length === 1) mCtx.lineTo(path.points[0].x * W + 0.1, path.points[0].y * H + 0.1);
|
||
mCtx.stroke();
|
||
}
|
||
mCtx.restore();
|
||
b.brush_base64 = mc.toDataURL("image/png");
|
||
} else { b.brush_base64 = ""; }
|
||
}
|
||
|
||
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(); drawCanvas(); updateTokens(); }
|
||
// Live text edit: persist + repaint + token count, without rebuilding the panel.
|
||
function touch() { serialize(); drawCanvas(); updateTokens(); }
|
||
|
||
function removeBox(i) {
|
||
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);
|
||
if (!idxs.length && node._activeIdx >= 0) idxs.push(node._activeIdx);
|
||
if (!idxs.length) return;
|
||
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();
|
||
}
|
||
|
||
// ── pointer interaction ──
|
||
canvasEl.addEventListener("pointerdown", (e) => {
|
||
const mN = mouseN(e);
|
||
if (node._isSketchMode) {
|
||
if (e.altKey) {
|
||
// Піпетка
|
||
try {
|
||
const rect = canvasEl.getBoundingClientRect();
|
||
const px = (e.clientX - rect.left) * (canvasEl.width / rect.width);
|
||
const py = (e.clientY - rect.top) * (canvasEl.height / rect.height);
|
||
const pixel = ctx.getImageData(px, py, 1, 1).data;
|
||
const hex = "#" + ("000000" + ((pixel[0] << 16) | (pixel[1] << 8) | pixel[2]).toString(16)).slice(-6);
|
||
if(node._syncSkColor) node._syncSkColor(hex);
|
||
} catch(err) { console.warn("CORS issue with image source for Eyedropper"); }
|
||
e.preventDefault(); e.stopPropagation(); return;
|
||
}
|
||
|
||
// Малювання
|
||
node._isDrawingSketch = true;
|
||
node._sketchHistory = [];
|
||
node._sketchPaths.push({ color: node._sketchColor, size: node._sketchSize, blur: node._sketchBlur, opacity: node._sketchOpacity, points: [mN] });
|
||
canvasEl.focus();
|
||
|
||
const onSkMove = (me) => {
|
||
if(node._isDrawingSketch) { node._sketchPaths[node._sketchPaths.length-1].points.push(mouseN(me)); drawCanvas(); }
|
||
};
|
||
const onSkUp = () => {
|
||
node._isDrawingSketch = false;
|
||
document.removeEventListener("pointermove", onSkMove);
|
||
document.removeEventListener("pointerup", onSkUp);
|
||
commit();
|
||
};
|
||
document.addEventListener("pointermove", onSkMove);
|
||
document.addEventListener("pointerup", onSkUp);
|
||
e.preventDefault(); e.stopPropagation(); drawCanvas(); return;
|
||
}
|
||
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
|
||
|
||
// 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;
|
||
|
||
// === KORA: Режим малювання пензлем (Shift + Ctrl) ===
|
||
if (node._isBrushMode || (e.shiftKey && (e.ctrlKey || e.metaKey))) {
|
||
startPainting(mN);
|
||
e.preventDefault(); e.stopPropagation();
|
||
return;
|
||
}
|
||
|
||
if (e.shiftKey) { // shift-drag = marquee select; shift-click = toggle
|
||
startMarquee(mN, hit ? hit.index : -1);
|
||
e.preventDefault(); e.stopPropagation();
|
||
return;
|
||
}
|
||
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] };
|
||
if (node._selection.size > 1) { // snapshot the whole group for group move/resize
|
||
node._groupStart = {};
|
||
for (const i of node._selection) 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;
|
||
document.addEventListener("pointermove", onMove);
|
||
document.addEventListener("pointerup", onUp);
|
||
document.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
|
||
});
|
||
|
||
canvasEl.addEventListener("pointermove", (e) => {
|
||
const mN = mouseN(e);
|
||
node._lastMouseN = mN;
|
||
|
||
// KORA: Управління курсором (Brush + Sketch + Eyedropper)
|
||
if (node._isSketchMode && e.altKey) {
|
||
if (node._cursorDiv) node._cursorDiv.style.display = "none";
|
||
canvasEl.style.cursor = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m2 22 1-1h3l9-9'/%3E%3Cpath d='M3 21v-3l9-9'/%3E%3Cpath d='m15 6 3.4-3.4a2.1 2.1 0 1 1 3 3L18 9l.4.4a2.1 2.1 0 1 1-3 3l-3.8-3.8a2.1 2.1 0 1 1 3-3l.4.4Z'/%3E%3C/svg%3E\") 0 24, crosshair";
|
||
} else if (node._isBrushMode || node._isSketchMode) {
|
||
updateCursorVisuals(e.clientX, e.clientY);
|
||
canvasEl.style.cursor = "none";
|
||
} else if (node._cursorDiv) {
|
||
node._cursorDiv.style.display = "none";
|
||
}
|
||
|
||
if (node._placing) { placeFollower(mN); return; }
|
||
if (node._drawing || node._marquee || node._hideBoxes) return;
|
||
|
||
const force = e.ctrlKey || e.metaKey;
|
||
const ti = force ? null : titleAt(mN);
|
||
const hit = force ? null : hitTest(mN);
|
||
const hb = ti != null ? ti : (hit ? hit.index : null);
|
||
if (ti !== node._hoverTitle || hb !== node._hoverBox) {
|
||
node._hoverTitle = ti; node._hoverBox = hb; drawCanvas();
|
||
}
|
||
|
||
// Kora
|
||
if (!node._isBrushMode && !node._isSketchMode) {
|
||
canvasEl.style.cursor = ti != null ? "pointer" : (hit ? (cursorForBboxMode(hit.mode) || "crosshair") : "crosshair");
|
||
}
|
||
});
|
||
canvasEl.addEventListener("pointerleave", () => {
|
||
if (hoveredCanvasNode === node) hoveredCanvasNode = null;
|
||
if (node._cursorDiv) node._cursorDiv.style.display = "none"
|
||
if (node._hoverTitle !== null || node._hoverBox !== null) {
|
||
node._hoverTitle = null; node._hoverBox = null; 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 = "kora-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.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) => JSON.parse(JSON.stringify(b)));
|
||
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(); fitNode();
|
||
}
|
||
// 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.
|
||
// Keyboard: Delete removes; Ctrl/Cmd C/V/D copy/paste/duplicate
|
||
|
||
node._syncBrush = () => { updateCursorVisuals(); drawCanvas(); };
|
||
|
||
canvasEl.addEventListener("keydown", (e) => {
|
||
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(); fitNode(); // 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();
|
||
}
|
||
});
|
||
// ── drawing Kora ──
|
||
function startPainting(mN) {
|
||
const b = node._boxes[node._activeIdx]; if (!b) return;
|
||
node._isDrawingMask = true;
|
||
if (!b.maskPaths) b.maskPaths = [];
|
||
b.maskPaths.push({ size: node._brushSize, blur: node._brushBlur, points: [mN] });
|
||
canvasEl.focus();
|
||
document.addEventListener("pointermove", onPaintMove);
|
||
document.addEventListener("pointerup", onPaintUp);
|
||
document.addEventListener("pointercancel", onPaintUp);
|
||
drawCanvas();
|
||
}
|
||
function onPaintMove(e) {
|
||
if (!node._isDrawingMask) return;
|
||
const b = node._boxes[node._activeIdx];
|
||
b.maskPaths[b.maskPaths.length - 1].points.push(mouseN(e));
|
||
drawCanvas();
|
||
}
|
||
function onPaintUp() {
|
||
node._isDrawingMask = false;
|
||
document.removeEventListener("pointermove", onPaintMove);
|
||
document.removeEventListener("pointerup", onPaintUp);
|
||
document.removeEventListener("pointercancel", onPaintUp);
|
||
commit();
|
||
}
|
||
|
||
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; // clamp delta so the whole group stays in bounds
|
||
for (const i in node._groupStart) {
|
||
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);
|
||
}
|
||
if (node.properties.snap) { // snap the group's movement to whole grid cells
|
||
const cell = Math.min(logW(), logH()) / gridN();
|
||
dx = Math.round(dx / (cell / logW())) * (cell / logW());
|
||
dy = Math.round(dy / (cell / logH())) * (cell / logH());
|
||
}
|
||
for (const i in node._groupStart) {
|
||
const s = node._groupStart[i];
|
||
node._boxes[i] = { ...s, x: s.x + dx, y: s.y + dy };
|
||
delete node._boxes[i].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,
|
||
});
|
||
delete node._boxes[i].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;
|
||
document.removeEventListener("pointermove", onMove);
|
||
document.removeEventListener("pointerup", onUp);
|
||
document.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();
|
||
document.addEventListener("pointermove", onMarqueeMove);
|
||
document.addEventListener("pointerup", onMarqueeUp);
|
||
document.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 (rectsOverlap(r, b)) sel.add(i); });
|
||
node._selection = sel;
|
||
if (node._activeIdx < 0 || !sel.has(node._activeIdx)) node._activeIdx = sel.size ? [...sel][0] : node._activeIdx;
|
||
}
|
||
drawCanvas();
|
||
}
|
||
function onMarqueeUp() {
|
||
document.removeEventListener("pointermove", onMarqueeMove);
|
||
document.removeEventListener("pointerup", onMarqueeUp);
|
||
document.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) return;
|
||
const nb = JSON.parse(JSON.stringify(src));
|
||
delete nb.nobbox;
|
||
node._boxes.push(nb);
|
||
selectOnly(node._boxes.length - 1);
|
||
node._placing = true;
|
||
canvasEl.focus();
|
||
canvasEl.style.cursor = "move";
|
||
serialize(); renderPanel(); drawCanvas(); updateTokens();
|
||
}
|
||
function finishPlacing() {
|
||
if (!node._placing) return;
|
||
node._placing = false;
|
||
canvasEl.style.cursor = "crosshair";
|
||
commit(); fitNode();
|
||
}
|
||
function cancelPlacing() {
|
||
if (!node._placing) return;
|
||
node._placing = false;
|
||
canvasEl.style.cursor = "crosshair";
|
||
removeBox(node._activeIdx);
|
||
commit(); fitNode();
|
||
}
|
||
|
||
// ── right-click "layers" menu: list / select / delete / duplicate / reorder regions ──
|
||
function closeLayersMenu() {
|
||
if (node._layerMenu) { node._layerMenu.remove(); node._layerMenu = null; }
|
||
if (node._layerMenuDismiss) {
|
||
document.removeEventListener("mousedown", node._layerMenuDismiss, true);
|
||
document.removeEventListener("pointerdown", node._layerMenuDismiss, true);
|
||
node._layerMenuDismiss = null;
|
||
}
|
||
}
|
||
function rowLabel(b) {
|
||
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 = "kora-menu";
|
||
const hdr = document.createElement("div");
|
||
hdr.className = "kora-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(".kora-lrow")).forEach((row, k) => {
|
||
row.querySelector(".kora-lnum").textContent = String(k + 1).padStart(2, "0");
|
||
});
|
||
function buildRows() {
|
||
list.innerHTML = "";
|
||
if (!node._boxes.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "kora-mhdr"; empty.textContent = "No regions yet.";
|
||
list.appendChild(empty);
|
||
return;
|
||
}
|
||
node._boxes.forEach((b, i) => {
|
||
const row = document.createElement("div");
|
||
row.className = "kora-lrow" + (i === node._activeIdx ? " active" : "");
|
||
row._box = b;
|
||
const sw = document.createElement("div");
|
||
sw.className = "kora-lsw";
|
||
sw.style.background = (b.palette || []).find(Boolean) || "#8c8c8c";
|
||
const num = document.createElement("span");
|
||
num.className = "kora-lnum"; num.textContent = String(i + 1).padStart(2, "0");
|
||
const txt = document.createElement("span");
|
||
const label = rowLabel(b);
|
||
txt.className = "kora-ltext" + (label ? "" : " empty");
|
||
txt.textContent = label || (b.type === "text" ? "(text)" : "(empty)");
|
||
txt.title = label;
|
||
const dup = document.createElement("button");
|
||
dup.className = "kora-lbtn"; dup.textContent = "⧉";
|
||
dup.title = "Duplicate, then click on the canvas to place";
|
||
const del = document.createElement("button");
|
||
del.className = "kora-lbtn del"; del.textContent = "✕";
|
||
del.title = "Delete region";
|
||
row.append(sw, num, txt, dup, del);
|
||
list.appendChild(row);
|
||
|
||
row.addEventListener("click", () => {
|
||
if (row._dragged) { row._dragged = false; return; }
|
||
selectOnly(node._boxes.indexOf(b));
|
||
commit();
|
||
for (const r of list.querySelectorAll(".kora-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(); fitNode();
|
||
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 === 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("kora-dragging");
|
||
}
|
||
for (const other of list.querySelectorAll(".kora-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(".kora-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("kora-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(".kora-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._layerMenuDismiss = (e) => { if (!menu.contains(e.target)) closeLayersMenu(); };
|
||
setTimeout(() => {
|
||
document.addEventListener("mousedown", node._layerMenuDismiss, true);
|
||
document.addEventListener("pointerdown", node._layerMenuDismiss, true);
|
||
}, 0);
|
||
}
|
||
|
||
canvasEl.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault(); e.stopPropagation();
|
||
if (node._placing || node._hideBoxes) return;
|
||
closeInlineEditor();
|
||
openLayersMenu(e.clientX, e.clientY);
|
||
});
|
||
stopProp(clearBtn);
|
||
clearBtn.addEventListener("click", () => {
|
||
closeInlineEditor();
|
||
// KORA:
|
||
node._isBrushMode = false;
|
||
if (node._cursorDiv) node._cursorDiv.style.display = "none";
|
||
canvasEl.style.cursor = "crosshair";
|
||
|
||
node._boxes = []; node._activeIdx = -1; node._selection = new Set(); node._stylePalette = [];
|
||
node._maskPaths = [];
|
||
node._lastImported = "";
|
||
commit(); rebuildStylePalette(); fitNode();
|
||
});
|
||
|
||
// ── 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];
|
||
}
|
||
function buildCaption() {
|
||
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.map((b) => {
|
||
const etype = b.type === "text" ? "text" : "obj";
|
||
const el = { type: etype };
|
||
if (!b.nobbox) el.bbox = normBboxJS(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 pyJson(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 = buildCaption();
|
||
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 : [];
|
||
node._boxes = els.map((el, i) => bboxElemToBox(el, i)).filter(Boolean);
|
||
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() : [];
|
||
}
|
||
function tryParseCaption(t) {
|
||
if (!t) return null;
|
||
try { const o = JSON.parse(t); return (o && typeof o === "object" && o.compositional_deconstruction) ? o : null; }
|
||
catch (e) { return null; }
|
||
}
|
||
// Apply a parsed caption to the editor and refresh everything.
|
||
function loadCaption(cap) {
|
||
closeInlineEditor();
|
||
applyCaption(cap);
|
||
syncCanvasToDims(); commit(); rebuildStylePalette(); fitNode();
|
||
}
|
||
// 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(); fitNode();
|
||
}
|
||
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(); fitNode();
|
||
}
|
||
}
|
||
// 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(); fitNode();
|
||
}
|
||
});
|
||
|
||
// ── property panel ──
|
||
function stopProp(el) {
|
||
for (const ev of ["mousedown", "pointerdown", "wheel"]) el.addEventListener(ev, (e) => 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 = "kora-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();
|
||
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("kora-dragging");
|
||
}
|
||
for (const other of container.querySelectorAll(".kora-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(".kora-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 = () => {
|
||
document.removeEventListener("pointermove", move);
|
||
document.removeEventListener("pointerup", up);
|
||
document.removeEventListener("pointercancel", up);
|
||
document.body.classList.remove("kora-dragging");
|
||
if (dragging) {
|
||
sw.classList.remove("dragging");
|
||
const order = Array.from(container.querySelectorAll(".kora-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
|
||
}
|
||
};
|
||
document.addEventListener("pointermove", move);
|
||
document.addEventListener("pointerup", up);
|
||
document.addEventListener("pointercancel", up);
|
||
});
|
||
});
|
||
if (arr.length < max) {
|
||
const add = document.createElement("button");
|
||
add.className = "kora-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(); fitNode(); });
|
||
}
|
||
|
||
// Textarea whose user-dragged height persists across panel rebuilds / box switches.
|
||
function makeArea(field, value, placeholder, onInput, defaultH) {
|
||
const ta = document.createElement("textarea");
|
||
ta.className = "kora-area";
|
||
ta.placeholder = placeholder;
|
||
ta.value = value || "";
|
||
const h = node._areaH[field] || defaultH;
|
||
if (h) ta.style.height = h + "px";
|
||
stopProp(ta);
|
||
ta.addEventListener("input", onInput);
|
||
const ro = new ResizeObserver(() => {
|
||
if (ta.offsetHeight > 0) { node._areaH[field] = ta.offsetHeight; fitNode(); }
|
||
});
|
||
ro.observe(ta);
|
||
node._areaObservers.push(ro);
|
||
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(); fitNode();
|
||
}
|
||
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(); fitNode();
|
||
}
|
||
function makeBboxField(placeholder, title, onCommit) {
|
||
const inp = document.createElement("input");
|
||
inp.type = "text"; inp.className = "kora-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 = "Drag on the canvas to add a region";
|
||
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);
|
||
if (node._panelH) panel.style.minHeight = node._panelH + "px"; // reserve height so it doesn't pop
|
||
requestAnimationFrame(fitNode);
|
||
return;
|
||
}
|
||
panel.style.minHeight = "";
|
||
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 = "kora-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 = "kora-btn" + (b.type === t ? " active" : "");
|
||
btn.textContent = t;
|
||
stopProp(btn);
|
||
btn.addEventListener("click", () => { b.type = t; commit(); });
|
||
typeRow.appendChild(btn);
|
||
}
|
||
const txtChk = document.createElement("label");
|
||
txtChk.style.cssText = "display:flex; align-items:center; gap:3px; cursor:pointer;";
|
||
txtChk.title = "Show each region's text inside its box on the canvas";
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox"; cb.checked = node.properties.showBoxText !== false;
|
||
stopProp(cb);
|
||
cb.addEventListener("change", () => { node.properties.showBoxText = cb.checked; drawCanvas(); });
|
||
txtChk.appendChild(cb); txtChk.appendChild(document.createTextNode("show text"));
|
||
typeRow.appendChild(txtChk);
|
||
const isolateBtn = document.createElement("button");
|
||
isolateBtn.className = "kora-btn" + (node._isolateActiveBox ? " active" : "");
|
||
isolateBtn.textContent = "👁️";
|
||
isolateBtn.title = "Unvisible other bbox";
|
||
isolateBtn.style.marginLeft = "4px";
|
||
stopProp(isolateBtn);
|
||
isolateBtn.addEventListener("click", () => {
|
||
node._isolateActiveBox = !node._isolateActiveBox;
|
||
isolateBtn.classList.toggle("active", node._isolateActiveBox);
|
||
drawCanvas();
|
||
});
|
||
typeRow.appendChild(isolateBtn);
|
||
//
|
||
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);
|
||
|
||
panel.appendChild(typeRow);
|
||
|
||
|
||
const maskRow = document.createElement("div"); maskRow.className = "kora-row";
|
||
|
||
const maskChkLbl = document.createElement("label"); maskChkLbl.style.cssText = "display:flex; align-items:center; gap:3px; cursor:pointer;";
|
||
const maskChk = document.createElement("input"); maskChk.type = "checkbox"; maskChk.checked = !!b.mask_enabled;
|
||
maskChkLbl.append(maskChk, document.createTextNode("Add mask"));
|
||
|
||
const opacLbl = document.createElement("span"); opacLbl.textContent = "opac:"; opacLbl.style.cssText = "color:#888; margin-left:5px;";
|
||
const opacNum = document.createElement("input"); opacNum.type = "number"; opacNum.min = "0"; opacNum.max = "100";
|
||
opacNum.value = b.mask_opacity !== undefined ? b.mask_opacity : 0;
|
||
opacNum.style.cssText = "width:50px; background:#111; border:1px solid #777; color:#fff; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
|
||
const blurLbl = document.createElement("span"); blurLbl.textContent = "blur:"; blurLbl.style.cssText = "color:#888; margin-left:5px;";
|
||
const blurNum = document.createElement("input"); blurNum.type = "number"; blurNum.min = "0"; blurNum.max = "300";
|
||
blurNum.value = b.mask_blur !== undefined ? b.mask_blur : 0;
|
||
blurNum.style.cssText = "width:50px; background:#111; border:1px solid #777; color:#fff; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
|
||
stopProp(maskChk); stopProp(opacNum); stopProp(blurNum);
|
||
|
||
maskChk.addEventListener("change", () => {
|
||
b.mask_enabled = maskChk.checked;
|
||
if (b.mask_enabled && (!b.mask_opacity || b.mask_opacity === 0)) { b.mask_opacity = 100; opacNum.value = 100; }
|
||
commit(); drawCanvas();
|
||
});
|
||
|
||
|
||
function addScrub(el, field, maxV) {
|
||
let isScr = false, sX = 0, sV = 0;
|
||
const onMv = (e) => {
|
||
if (!isScr) return;
|
||
el.value = Math.max(0, Math.min(maxV, sV + Math.round((e.clientX - sX) / 2)));
|
||
b[field] = parseInt(el.value, 10);
|
||
if (field === "brush_blur") { node._brushBlur = b[field]; updateCursorVisuals(); } // Оновлюємо курсор
|
||
drawCanvas();
|
||
};
|
||
const onUp = () => { if (isScr) { isScr = false; commit(); window.removeEventListener("pointermove", onMv); window.removeEventListener("pointerup", onUp); } };
|
||
el.addEventListener("pointerdown", (e) => { isScr = true; sX = e.clientX; sV = parseInt(el.value, 10) || 0; el.focus(); window.addEventListener("pointermove", onMv); window.addEventListener("pointerup", onUp); });
|
||
el.addEventListener("input", () => {
|
||
b[field] = parseInt(el.value, 10) || 0;
|
||
if (field === "brush_blur") { node._brushBlur = b[field]; updateCursorVisuals(); } // Оновлюємо курсор
|
||
commit(); drawCanvas();
|
||
});
|
||
}
|
||
addScrub(opacNum, "mask_opacity", 100);
|
||
addScrub(blurNum, "mask_blur", 100);
|
||
|
||
maskRow.append(maskChkLbl, opacLbl, opacNum, blurLbl, blurNum);
|
||
panel.appendChild(maskRow);
|
||
|
||
|
||
const brushRow = document.createElement("div"); brushRow.className = "kora-row";
|
||
const brushBtn = document.createElement("button");
|
||
brushBtn.className = "kora-btn" + (node._isBrushMode ? " active" : "");
|
||
brushBtn.textContent = "Brush (B) 🖌️";
|
||
if (node._isBrushMode) {
|
||
brushBtn.style.cssText = "background-color: #ef4444; color: white; box-shadow: 0 0 10px #ef4444; border: 1px solid #ffaaaa;";
|
||
}
|
||
|
||
node._toggleBrushMode = () => {
|
||
if (node._activeIdx < 0) return;
|
||
node._isBrushMode = !node._isBrushMode;
|
||
brushBtn.classList.toggle("active", node._isBrushMode);
|
||
|
||
if (node._isBrushMode) {
|
||
// Вмикаємо червоне світіння
|
||
brushBtn.style.cssText = "background-color: #ef4444; color: white; box-shadow: 0 0 10px #ef4444; border: 1px solid #ffaaaa;";
|
||
canvasEl.style.cursor = "none";
|
||
if (node._cursorDiv && node._lastMouseN) { node._brushBlur = b.brush_blur || 5; updateCursorVisuals(); }
|
||
} else {
|
||
brushBtn.style.cssText = "";
|
||
canvasEl.style.cursor = "crosshair";
|
||
if (node._cursorDiv) node._cursorDiv.style.display = "none";
|
||
}
|
||
};
|
||
brushBtn.addEventListener("click", () => node._toggleBrushMode());
|
||
|
||
const brBlurLbl = document.createElement("span"); brBlurLbl.textContent = "blur:"; brBlurLbl.style.cssText = "color:#888; margin-left:5px;";
|
||
const brBlurNum = document.createElement("input"); brBlurNum.type = "number"; brBlurNum.min = "0"; brBlurNum.max = "50";
|
||
brBlurNum.value = b.brush_blur !== undefined ? b.brush_blur : 5;
|
||
brBlurNum.style.cssText = "width:50px; background:#111; border:1px solid #777; color:#fff; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
stopProp(brBlurNum);
|
||
addScrub(brBlurNum, "brush_blur", 50);
|
||
const brSizeLbl = document.createElement("span"); brSizeLbl.textContent = "size:"; brSizeLbl.style.cssText = "color:#888; margin-left:5px;";
|
||
const brSizeNum = document.createElement("input"); brSizeNum.type = "number"; brSizeNum.min = "1"; brSizeNum.max = "600";
|
||
brSizeNum.value = node._brushSize || 20;
|
||
brSizeNum.title = "Change brush size use [ ]";
|
||
brSizeNum.style.cssText = "width:55px; background:#111; border:1px solid #777; color:#fff; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
stopProp(brSizeNum);
|
||
|
||
let isBrScr = false, sBrX = 0, sBrV = 0;
|
||
const onBrMv = (e) => {
|
||
if (!isBrScr) return;
|
||
brSizeNum.value = Math.max(1, Math.min(600, sBrV + Math.round((e.clientX - sBrX) / 2)));
|
||
node._brushSize = parseInt(brSizeNum.value, 10); updateCursorVisuals();
|
||
};
|
||
const onBrUp = () => { if (isBrScr) { isBrScr = false; window.removeEventListener("pointermove", onBrMv); window.removeEventListener("pointerup", onBrUp); } };
|
||
brSizeNum.addEventListener("pointerdown", (e) => { isBrScr = true; sBrX = e.clientX; sBrV = parseInt(brSizeNum.value, 10) || 20; brSizeNum.focus(); window.addEventListener("pointermove", onBrMv); window.addEventListener("pointerup", onBrUp); });
|
||
brSizeNum.addEventListener("input", () => { node._brushSize = parseInt(brSizeNum.value, 10) || 20; updateCursorVisuals(); });
|
||
node._syncBrush = () => { brSizeNum.value = node._brushSize; updateCursorVisuals(); drawCanvas(); };
|
||
const brOpacLbl = document.createElement("span"); brOpacLbl.textContent = "opac:"; brOpacLbl.style.cssText = "color:#888; margin-left:5px;";
|
||
const brOpacNum = document.createElement("input"); brOpacNum.type = "number"; brOpacNum.min = "0"; brOpacNum.max = "100"; brOpacNum.value = b.brush_opacity !== undefined ? b.brush_opacity : 100;
|
||
brOpacNum.style.cssText = "width:50px; background:#111; border:1px solid #777; color:#fff; font-size:12px; font-family:monospace; border-radius:3px; padding:2px; cursor:ew-resize;";
|
||
stopProp(brOpacNum);
|
||
addScrub(brOpacNum, "brush_opacity", 100);
|
||
|
||
const clearBrushBtn = document.createElement("button"); clearBrushBtn.className = "kora-btn"; clearBrushBtn.textContent = "Reset";
|
||
clearBrushBtn.addEventListener("click", () => { b.maskPaths = []; commit(); });
|
||
|
||
brushRow.append(brushBtn, brSizeLbl, brSizeNum, brOpacLbl, brOpacNum, brBlurLbl, brBlurNum, clearBrushBtn);
|
||
panel.appendChild(brushRow);
|
||
|
||
// 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(); }));
|
||
}
|
||
|
||
// desc — default ~3x the single-line min height
|
||
panel.appendChild(makeArea("desc", b.desc, "description of this region",
|
||
function () { b.desc = this.value; touch(); }, 110));
|
||
|
||
// palette
|
||
const palRow = document.createElement("div");
|
||
palRow.className = "kora-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);
|
||
|
||
requestAnimationFrame(() => { node._panelH = panel.offsetHeight; fitNode(); }); // remember height for the deselected placeholder
|
||
}
|
||
|
||
// ── width/height widget callbacks ──
|
||
for (const w of [wWidget, hWidget]) {
|
||
if (!w) continue;
|
||
chainCallback(w, "callback", () => { syncCanvasToDims(); drawCanvas(); fitNode(); });
|
||
}
|
||
// 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());
|
||
}
|
||
|
||
// ── keep canvas + getMinHeight in sync while the node is resized ──
|
||
let _resizing = false;
|
||
chainCallback(node, "onResize", function () {
|
||
if (_resizing || node._fullscreen) return;
|
||
_resizing = true;
|
||
recalcWidgetHeight();
|
||
|
||
const MIN_WIDTH = 680;
|
||
if (node.size[0] < MIN_WIDTH) node.size[0] = MIN_WIDTH;
|
||
|
||
// Resize clamp reads computeSize() before getMinHeight refreshes; re-grow with fresh min.
|
||
const minH = node.computeSize()[1];
|
||
if (node.size[1] < minH) node.size[1] = minH;
|
||
drawCanvas();
|
||
_resizing = false;
|
||
});
|
||
|
||
// Optional reference image as the canvas background (matches ImageTransformKJ).
|
||
function loadBg(src) {
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.onload = () => {
|
||
node._bgImg = img;
|
||
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(); fitNode(); 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));
|
||
};
|
||
|
||
// 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);
|
||
if (node._cursorDiv) node._cursorDiv.remove();
|
||
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();
|
||
}
|
||
node._visObserver?.disconnect();
|
||
closeBgMenu(); node._bgMenu?.remove();
|
||
closeTplMenu(); node._tplMenu?.remove();
|
||
closeInlineEditor();
|
||
closeLayersMenu();
|
||
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) o.ideo = { boxes: node._boxes, palette: node._stylePalette, importMode: findW("import_mode")?.value };
|
||
});
|
||
chainCallback(node, "onConfigure", function (o) {
|
||
const raw = o && Array.isArray(o.widgets_values) ? o.widgets_values : [];
|
||
// 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 im = o && o.ideo && o.ideo.importMode, imW = findW("import_mode");
|
||
if (im && imW) imW.value = im; // restore import_mode (index-based restore is unreliable here)
|
||
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;
|
||
syncCanvasToDims();
|
||
rebuildStylePalette();
|
||
renderPanel();
|
||
drawCanvas();
|
||
updateTokens();
|
||
requestAnimationFrame(fitNode);
|
||
});
|
||
|
||
// initial layout (deferred so size/last_y are settled)
|
||
setTimeout(() => {
|
||
hideDataWidgets();
|
||
if (node.size[0] < 680) node.setSize([680, node.size[1]]);
|
||
syncCanvasToDims();
|
||
rebuildStylePalette();
|
||
renderPanel();
|
||
drawCanvas();
|
||
updateTokens();
|
||
fitNode();
|
||
}, 0);
|
||
});
|
||
},
|
||
});
|