/** * Foundations for the H3 Ref Chain node editor. * * Two things here are load-bearing and neither is obvious, so both are * documented at the point of use rather than in a README nobody opens: * * hideWidget() - the four-flag recipe. Classic LiteGraph only needed * computeSize = [0, -4]; Vue Nodes 2.0 filters on * options.hidden | hideInPanel | canvasOnly, and without * those flags every hidden dial reappears as a raw form. * Invisible in packs with three widgets, unmissable at 21. * * installHeightGuard() - the _h fixpoint. LiteGraph's _arrangeWidgets runs * every frame; report a height derived from node.size[1] and * the node grows forever. * * Both are ported from PromptMasterLD/js/claude_prompt.js, which paid for them. */ import { app } from "../../../scripts/app.js"; const VOCAB_URL = "/h3_ref_chain/vocab"; /* -- vocabulary ---------------------------------------------------------- */ let _vocabPromise = null; const _vocabListeners = new Set(); /** * The directive vocabulary, fetched once per browser session. * * Deliberately NOT bundled into this file: `directives.py` is the single source * of truth so that improving one sentence improves every plan already written. * A copy here would go stale on the first edit. */ /** Drop the cached vocabulary so the next vocab() call refetches. */ export function refreshVocab() { _vocabPromise = null; for (const fn of _vocabListeners) { try { fn(); } catch (err) { console.error("[HandTieClips] vocab listener failed:", err); } } } /** Register a mounted editor to be re-rendered when the vocabulary reloads. */ export function onVocabRefresh(fn) { _vocabListeners.add(fn); return () => _vocabListeners.delete(fn); } export function vocab() { if (!_vocabPromise) { _vocabPromise = fetch(VOCAB_URL) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) .catch((err) => { console.error("[HandTieClips] vocabulary unavailable:", err); // Degrade to free-text directives rather than an empty editor. // `_failed` is read by the editors, which show a retry strip -- // it used to be set here and never looked at again, so a failed // fetch just produced a mysteriously featureless panel. return { axes: [], vocab: {}, defaults: {}, retention: {}, durations: {}, overlaps: {}, max_ref_images: 9, fps: 24, _failed: true, _error: String(err && err.message || err) }; }); } return _vocabPromise; } /* -- widget visibility --------------------------------------------------- */ /** * Hide a native widget without losing its value. * * The widget still serializes -- that is the entire point. The DOM editor is a * rendering layer over `shot_plan` / `ref_plan`, and those widgets remain the * only source of truth, so the workflow JSON is byte-identical in shape to one * authored by hand. */ /* `widget.inputEl` is a deprecated alias on frontend 1.49.6: merely reading it * logs a deprecation warning to the console. Prefer `element`, and only reach * for the alias when it is absent, i.e. on an older frontend. */ function widgetElements(w) { if (w.element) return [w.element]; return w.inputEl ? [w.inputEl] : []; } /** * The widget's type as the FRONTEND defined it, seeing past our own hiding. * * `hideWidget` overwrites `w.type` with "hidden" and stashes the real one in * `_h3Saved`. Anything that renders a widget therefore has to ask for the type * this way, or it reads back the hiding rather than the widget -- which is * exactly how the run panel emptied itself: it hid the dials it owned on the * first build, then found nothing but "hidden" on the rebuild. */ export function widgetType(w) { const t = (w && w._h3Hidden) ? w._h3Saved?.type : w?.type; return String(t ?? "").toLowerCase(); } /** Ditto for options: `hideWidget` replaces the object with a flagged copy. */ export function widgetOptions(w) { const o = (w && w._h3Hidden) ? w._h3Saved?.options : w?.options; return o || w?.options || {}; } export function hideWidget(w) { if (!w || w._h3Hidden) return; w._h3Hidden = true; w._h3Saved = { type: w.type, computeSize: w.computeSize, computeLayoutSize: w.computeLayoutSize, draw: w.draw, options: w.options, }; w.type = "hidden"; w.hidden = true; w.options = Object.assign({}, w.options || {}, { hidden: true, hideInPanel: true, // canvasOnly also drops it from the Vue widget list (shouldRenderAsVue). canvasOnly: true, // Never flip serialization off: the value IS the saved document. serialize: w.options?.serialize !== false, }); w.computeSize = () => [0, -4]; if (typeof w.computeLayoutSize === "function") { w.computeLayoutSize = () => ({ minHeight: 0, maxHeight: 0, minWidth: 0 }); } w.draw = () => {}; // A multiline STRING widget is a real DOM textarea. Collapsing only the // LiteGraph geometry leaves it floating at its old coordinates, on top of // the editor. for (const el of widgetElements(w)) { if (!el?.style) continue; el.style.setProperty("display", "none", "important"); el.setAttribute?.("aria-hidden", "true"); } } /** Undo hideWidget. Used when the Simple/Shots toggle flips. */ export function showWidget(w) { if (!w?._h3Hidden) return; const saved = w._h3Saved || {}; w.type = saved.type; w.hidden = false; w.options = saved.options || {}; if (saved.computeSize) w.computeSize = saved.computeSize; else delete w.computeSize; if (saved.computeLayoutSize) w.computeLayoutSize = saved.computeLayoutSize; if (saved.draw) w.draw = saved.draw; else delete w.draw; for (const el of widgetElements(w)) { if (!el?.style) continue; el.style.removeProperty("display"); el.removeAttribute?.("aria-hidden"); } w._h3Hidden = false; delete w._h3Saved; } export function widgetByName(node, name) { return node.widgets?.find((w) => w.name === name) || null; } /** * The `tooltip` a widget was given in INPUT_TYPES, wherever this frontend * keeps it. * * `widgetOptions(w).tooltip` on its own is not enough, and returned nothing * for the entire RUN panel: ComfyUI's own tooltip layer does not read the * widget object at all, it reads the node DEFINITION. So every native dial * showed its help text while every field this editor drew showed none, which * reads as "the panel has no tooltips" rather than as a lookup miss. * * Which of the three places holds the string depends on the frontend version, * so all three are tried instead of pinning one. Never retype the sentences * here: INPUT_TYPES is the single source, for the same reason routes.py exists. */ export function tooltipFor(node, w) { if (!w) return ""; const direct = w.tooltip || widgetOptions(w).tooltip; if (direct) return String(direct); const spec = node?.constructor?.nodeData?.input; const entry = spec?.required?.[w.name] || spec?.optional?.[w.name]; const tip = Array.isArray(entry) ? entry[1]?.tooltip : null; return tip ? String(tip) : ""; } /** Hide every widget in `names`, show the rest of `names`' complement set. */ export function setWidgetVisibility(node, hiddenNames) { const hide = new Set(hiddenNames); for (const w of node.widgets || []) { if (hide.has(w.name)) hideWidget(w); else showWidget(w); } } /* -- height guard -------------------------------------------------------- */ /** * Stop the node growing a little every frame. * * LiteGraph's _arrangeWidgets does, once per frame: * * l = widgetsTop; for (w of widgets) l += w.computeSize()[1] + 4; * if (l > node.size[1]) node.setSize([node.size[0], l]); * * so a panel that reports `node.size[1]` makes l = top + size[1] + 4 > size[1] * on EVERY frame. Observed at ~130px of growth per frame -- the workflow * scrolls away from you while you watch it. * * Reporting `size[1] - chrome() - SLACK` makes l <= size[1] on every frame, so * the arrange pass never asks to grow and the loop settles at the node's own * height. SLACK is what buys the inequality: panelTop is chrome minus the * node's bottom padding, so a panel of exactly `size - chrome` still asks for * up to 4px more than the node has -- every frame, forever. * * This replaced an independent stored `_h`, updated through an onResize hook. * The hook fired (57 times in one measured drag) but its `Array.isArray(size)` * gate rejected every call -- this frontend's `node.size` is not a plain Array * -- so `_h` kept the height the node had at install time and the panel stayed * 742px inside a 1911px node. Reading the node directly has no such gate to get * wrong. * * The second floor is the resize drag, which clamps up to * LGraphNode.computeSize()[1]. Rather than re-derive that formula (and have it * rot the next time the frontend changes), measure it: with `_measuring` set, * panelHeight() reports 0, so computeSize() returns pure chrome. The flag also * breaks the recursion, since computeSize() calls back into us. */ export function installHeightGuard(node, domWidget, { minHeight = 420, minWidth = 520 } = {}) { if (node._h3HeightGuard) return node._h3HeightGuard; let measuring = false; let chromeVal = null; let chromeKey = ""; const chrome = () => { // The hidden count is part of the key because hiding a widget changes // what computeSize() measures while leaving widgets.length alone -- // so without it the cached chrome height goes stale the moment // applyVisibility runs, and every panel height after that is wrong. const hidden = (node.widgets || []).reduce((n, w) => n + (w._h3Hidden ? 1 : 0), 0); const key = `${node.inputs?.length}|${node.outputs?.length}|${node.widgets?.length}|${hidden}`; if (key === chromeKey && chromeVal != null) return chromeVal; measuring = true; try { chromeVal = +node.computeSize()[1] || 0; chromeKey = key; } catch (_) { chromeVal = 0; } finally { measuring = false; } return chromeVal; }; // The margin that keeps the arrange pass from asking for one more pixel // than the node has. See the note above. Costs an invisible strip at the // bottom of the panel; 4 would do, 8 leaves room for the frontend to // change its mind about padding. const SLACK = 8; const nodeHeight = () => Math.max(minHeight, +(node.size?.[1]) || minHeight); const nodeWidth = () => Math.max(minWidth, +(node.size?.[0]) || minWidth); const guard = { get minHeight() { return minHeight; }, /** The node's own height is the authority; nothing mirrors it. */ get _h() { return nodeHeight(); }, panelHeight() { if (measuring) return 0; return Math.max(minHeight, nodeHeight() - chrome() - SLACK); }, /** Grow the node if it is too short to hold a minimum panel. Never * shrinks it -- the node's height is the user's to choose. */ sync() { const need = chrome() + minHeight + SLACK; if (nodeHeight() < need - 1) node.setSize([nodeWidth(), need]); node.graph?.setDirtyCanvas?.(true, true); }, /** Ask for a taller panel, e.g. after adding a card. */ grow(px) { node.setSize([nodeWidth(), Math.max(minHeight, nodeHeight() + px)]); node.graph?.setDirtyCanvas?.(true, true); }, }; node._h3HeightGuard = guard; if (domWidget) { // The two callers want different answers and, helpfully, pass // different arguments -- which is the only way to tell them apart: // // LGraphNode.computeSize() calls computeSize(size[0]), WITH a width. // Its result is used purely as a FLOOR (the resize-drag clamp, // expandToFitContent). Reporting the live height there pins the // floor to the current height, so the node can only ever grow -- // drag it smaller and nothing happens. Report the MINIMUM. // // _arrangeWidgets() calls computeSize(), no argument. // The real layout pass. Report the live height. domWidget.computeSize = (width) => [ Math.max(minWidth, +(node.size?.[0]) || minWidth), measuring ? 0 : (width === undefined ? guard.panelHeight() : minHeight), ]; if (typeof domWidget.computeLayoutSize === "function") { // TRACK, DO NOT FREEZE. Reporting a constant `minWidth` here is // what collapses the node: the layout pass re-reads it on every // recompute -- selecting the node is enough -- and faithfully // re-declares the node at its stated minimum, so a wide node // becomes a 560px node on the first click and stays there. // Safe against feedback: max() against a constant is a fixpoint, // not an accumulator like the height chain, and width never feeds // height. domWidget.computeLayoutSize = () => ({ minHeight: measuring ? 0 : guard.panelHeight(), maxHeight: measuring ? 0 : guard.panelHeight(), minWidth: Math.max(minWidth, +(node.size?.[0]) || minWidth), }); } // The other half of the collapse. ComfyUI's DOM-widget position // updater computes `size = [(widget.width ?? node.width) - margin*2, ...]`. // Once anything writes a stale number onto widget.width, node.width is // never consulted again -- which is why fixing only the layout size is // not enough. A live getter makes the stale value unrepresentable: // width always IS the node's width, and writes are dropped. try { Object.defineProperty(domWidget, "width", { get: () => Math.max(minWidth, +(node.size?.[0]) || minWidth), set: () => {}, configurable: true, }); } catch (err) { console.warn("[HandTieClips] width getter failed:", err); } } // With no widget declaring a width, LiteGraph falls back to // NODE_WIDTH * 1.5 = 210, and every resize command is then free to crush // the panel. Floor it. const prevComputeSize = node.computeSize; if (typeof prevComputeSize === "function") { node.computeSize = function (...args) { const r = prevComputeSize.apply(this, args) || [0, 0]; if (r[0] < minWidth) r[0] = minWidth; return r; }; } // No onResize hook any more. There is nothing to mirror: panelHeight() // reads node.size[1] at the moment it is asked, so a resize is reflected // by the very next arrange pass with nothing to keep in step. return guard; } /* -- tiny DOM helpers ---------------------------------------------------- */ export function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } export function button(label, title, onClick, className = "h3e-btn") { const b = el("button", className, label); b.type = "button"; if (title) b.title = title; b.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onClick(e); }); return b; } export function select(options, value, onChange, { blankLabel = "—", titles = {} } = {}) { const s = el("select", "h3e-select"); const blank = el("option", null, blankLabel); blank.value = ""; s.appendChild(blank); for (const opt of options) { const o = el("option", null, opt.replace(/_/g, " ")); o.value = opt; if (titles[opt]) o.title = titles[opt]; s.appendChild(o); } s.value = value || ""; s.addEventListener("change", () => onChange(s.value)); return s; } /** * Keep canvas gestures out of the panel. * * Without this a click inside a textarea also starts a node drag, and a scroll * inside the shot list zooms the graph instead. */ export function isolateEvents(root) { for (const name of ["pointerdown", "pointerup", "mousedown", "mouseup", "click", "dblclick", "contextmenu"]) { root.addEventListener(name, (e) => e.stopPropagation()); } root.addEventListener("keydown", (e) => e.stopPropagation()); // Wheel is NOT simply swallowed. Stopping it outright meant the graph could // not be zoomed while the pointer was anywhere over the panel -- and the // panel is most of the node. Scroll the nearest scrollable ancestor if // there is somewhere left to scroll; otherwise hand the gesture to the // canvas so zoom keeps working. Same approach as KJNodes' wheel passthrough. root.addEventListener("wheel", (e) => { let n = e.target; while (n && n !== root.parentNode) { if (n.scrollHeight > n.clientHeight + 1) { const style = getComputedStyle(n).overflowY; if (style === "auto" || style === "scroll") { const atTop = n.scrollTop <= 0; const atEnd = n.scrollTop + n.clientHeight >= n.scrollHeight - 1; if (!((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atEnd))) { e.stopPropagation(); return; } } } n = n.parentNode; } const canvasEl = document.querySelector("#graph-canvas") || app?.canvas?.canvas; if (!canvasEl) return; e.preventDefault(); e.stopPropagation(); canvasEl.dispatchEvent(new WheelEvent(e.type, e)); }, { passive: false }); }