ComfyUI-Hand-Tie-Clips / js /editor /widget_utils.js
Sandpies
0.4.4: fix what two models proved the prompt never said
ddcc824
Raw
History Blame Contribute Delete
18.3 kB
/**
* 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 });
}