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 });
}