fix(canvas): the template editor stopped fighting the node over its height

Three defects, each independently wrong, all pushing on the same thing. Keeping
the grip rather than moving to `fillHost`: the assembler has TWO widgets, and
`fillHost` means the node's size drives the widget — with two of them sharing
the node height, dragging the corner would stretch the chip strip and preview
too. Debug gets away with it because it is the only widget on its node.

`autoHeight` was copied onto the template editor from the Context and Injector
widgets without their reason for it. Those need it because a node stuck at a
manually-set tall height breaks their collapse animation; this widget has no
collapse animation. And the flag is precisely the branch that discards a user's
height:

    const userControlsHeight = !options.autoHeight && …
    const rawTargetH = userControlsHeight ? Math.max(cur[1], min[1]) : min[1];

With it off, the default path already does what a template editor wants —
preserve the height the user settled on, grow only when content needs more. The
mechanism was there; the flag was switching it off.

`followGrip` now runs only while a grip is actually held, which is what its name
always implied — it exists to keep the handle under the cursor. Ungated it also
fired on content-driven growth, so typing a long enough template scrolled the
page out from under you, and it made the resize flush mutate scroll position.
That moves the element the observer is measuring: a resize handler that can
change its own input is the shape a runaway needs.

`capLifted` now re-arms on pointerup. It guards a one-time "pin the current
height, release the max-height cap" step, and leaving it set meant the release
outlived the explicit height that bounded it — `applyAtoms` replaces the host
element on a rebuild, dropping the inline height but not the flag, so the next
drag found an uncapped, unpinned box.

Both new tests confirmed to fail with their fix reverted. Still not reproduced
end to end: the harness here runs the page as a hidden tab where neither rAF nor
ResizeObserver fires, so the drag cannot be exercised. Each change stands on
inspection; which of them was the reported symptom is for a real pointer to say.
This commit is contained in:
DumiFlex
2026-08-07 15:12:42 +03:00
parent 5c164bb6ee
commit bc8421fed3
3 changed files with 81 additions and 2 deletions
@@ -286,3 +286,54 @@ describe("useGrowableField — re-measures after the webfonts land", () => {
wrap.unmount();
});
});
describe("useGrowableField — grip-follow only while a grip is held", () => {
/**
* `followGrip` keeps the resize handle under the cursor by scrolling the
* container by the overshoot. Ungated it also ran on content-driven growth,
* so typing a long enough value scrolled the page out from under the user —
* and it made the resize flush mutate scroll position, which moves the very
* element the observer is measuring.
*/
function mountInScroller(height: number) {
const scroller = document.createElement("div");
scroller.style.overflowY = "auto";
document.body.appendChild(scroller);
const { wrap, api, node } = mountField(height);
scroller.appendChild(node);
scroller.getBoundingClientRect = () => ({
height: 50, bottom: 50, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
scroller.scrollTop = 0;
return { wrap, api, node, scroller };
}
it("does not scroll the container when the content simply grew", () => {
const { wrap, node, scroller } = mountInScroller(100);
// Element now overshoots the scroller's bottom edge by a long way.
node.getBoundingClientRect = () => ({
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
observerCallback?.();
expect(scroller.scrollTop).toBe(0);
wrap.unmount();
scroller.remove();
});
it("does scroll while the handle is actually being dragged", () => {
const { wrap, api, node, scroller } = mountInScroller(100);
api.startResize({
clientY: 10, pointerId: 1, preventDefault() {}, currentTarget: null,
} as unknown as PointerEvent);
node.getBoundingClientRect = () => ({
height: 400, bottom: 400, top: 0, left: 0, right: 0, width: 100, x: 0, y: 0,
toJSON: () => ({}),
}) as DOMRect;
observerCallback?.();
expect(scroller.scrollTop).toBeGreaterThan(0);
wrap.unmount();
scroller.remove();
});
});
+17 -1
View File
@@ -165,7 +165,15 @@ export function useGrowableField(
// cursor. Deferring this to pointerup (an earlier attempt at the stall,
// which turned out to be the height cap) just made the field vanish off
// the bottom for the whole drag.
if (h > lastHeight) followGrip();
//
// Gated on `dragging`, which is what the name always implied. It exists to
// keep the GRIP under the CURSOR, and there is no grip being held unless a
// drag is in progress. Ungated it also ran on content-driven growth — so
// typing a long enough template scrolled the page out from under you — and
// it made this flush mutate scroll position, which moves the element the
// observer is measuring. A resize handler that can change its own input is
// the shape a runaway needs.
if (dragging && h > lastHeight) followGrip();
lastHeight = h;
}
@@ -248,6 +256,14 @@ export function useGrowableField(
function onPointerUp(): void {
if (!dragging) return;
dragging = false;
// Re-arm for the next drag. `capLifted` guards a one-time "pin the current
// height, release the max-height cap" step, and leaving it set meant the
// release survived for the lifetime of the field while the explicit height
// that bounded it did not — `applyAtoms` replaces the host element on a
// rebuild, which drops the inline height but not this flag. The next drag
// then found an uncapped, unpinned box. Each drag now re-establishes both
// against the element that actually exists.
capLifted = false;
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
+13 -1
View File
@@ -139,7 +139,19 @@ export function create(node: EditorNode, inputName: string) {
socketed: true,
minHeight: 96,
minWidth: 300,
autoHeight: true,
// Deliberately NOT `autoHeight`. That flag means "always follow content
// height, ignore the user's drag", and it was copied here from the Context
// and Injector widgets without their reason for it: those need it because a
// node stuck at a manually-set tall height breaks their collapse animation.
// This widget has no collapse animation, and the flag was actively hostile
// — it is the branch that makes `pushSize` discard a user height:
//
// const userControlsHeight = !options.autoHeight && …
// const rawTargetH = userControlsHeight ? Math.max(cur[1], min[1]) : min[1];
//
// With it off, the default path already does what a template editor wants:
// preserve whatever height the user settled on, and grow only when the
// content genuinely needs more room.
// Fired by ComfyUI's own value setter, i.e. workflow load and undo.
onValueRestored: (v: string) => { model.value = v; },
});