File size: 18,280 Bytes
7c2113f ddcc824 7c2113f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 | /**
* 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 });
}
|