refactor: implement user-controlled resizing for dual panel subgraph viewer

This commit is contained in:
nomadoor
2025-10-19 10:32:32 +09:00
parent dcba390c70
commit 47caeef292
3 changed files with 326 additions and 2 deletions
@@ -25,3 +25,11 @@
- **Event listener cleanup:** Touch focus handlers were previously registered with `{ passive: true }` but removed without matching options. Matching add/remove signatures prevents leaked listeners on Safari/iOS.
- **Bug fix regressions:** A regressions introduced while refreshing the host calculation referenced an undefined `canvasElement`, preventing the panel from opening. Restored correct usage of `currentCanvas.parentElement`, eliminating the runtime `ReferenceError`.
- **Observed backend 404s:** The Vue frontend logs `api/userdata/...` 404s when optional user assets (templates, CSS) are absent. These are expected in a clean setup and unrelated to the extension; documented here to avoid misattributing them to the panel logic.
- **Open issue (2025-10-18): Subgraph panel cannot host DOM-based widgets (e.g. CLIP Text Encode multiline prompt).**
ComfyUI renders multiline prompts as DOM widgets (`addDOMWidget`) via a separate Vue+Pinia layer tied to the main canvas. Our dual-panel canvas does not participate in that lifecycle, so the widgets are not promoted/visible and user input is impossible without a deeper frontend change. Until the upstream DOM widget layer supports multiple canvases, we treat multiline prompts as read-only inside the panel.
- **Open issue (2025-10-18): Node toolbars are missing inside the subgraph panel.**
The Vue toolbar overlay relies on `useCanvasStore()` (Pinia) watching the active canvas; our secondary `LGraphCanvas` is not registered with that store, so the overlay never renders. Enabling it would require upstream support for multiple canvases inside the front-end store.
- **Open issue (2025-10-18): Node toolbars are missing inside the subgraph panel.**
The Vue toolbar overlay relies on `useCanvasStore()` (Pinia) watching the active canvas; our secondary LGraphCanvas is not registered with that store, so the overlay never renders. Enabling it would require upstream support for multiple canvases inside the front-end store.
+21
View File
@@ -0,0 +1,21 @@
# 2025-10-19 Dual Panel Resize Persistence
## Context
- Requirement `000-dual-panel-subgraph` now needs the subgraph side panel to support user-controlled widths instead of the fixed CSS-based size.
- The existing JavaScript extension already injects the panel and manages layout through the `--dual-panel-subgraph-width` CSS variable, making it a natural hook for resizing logic.
- We must keep the implementation non-invasive and compatible with the Vue/TypeScript frontend, following the documented `app.registerExtension()` workflow.
## Decision
- Introduced a dedicated resize handle (`.dual-panel-subgraph__resize-handle`) that listens for pointer events and adjusts the panel width on drag.
- Clamped panel widths between 320px and `viewport - 360px` to preserve a usable main canvas, updating the CSS variable on every change.
- Persisted the most recent width in `localStorage` under the key `dualPanel.subgraph.width.v1`, restoring it whenever the panel reopens.
- Added a temporary body class while resizing to give user feedback, suppress unwanted text selection, and disable layout transitions that caused visible gaps.
- Throttled canvas resizes with `requestAnimationFrame` during pointer moves to keep drag interactions responsive while still redrawing at the next frame.
- When the panel is open, detected the existing PrimeVue canvas toolbar (`.p-buttongroup.p-component.p-buttongroup-vertical`) and the minimap container (`.minimap-main-container`), pinning both to the viewport with fixed positioning so they stay anchored while the panel resizes the main canvas host.
## Consequences
- Users can resize the panel freely, and their preferred width survives reloads in the same browser profile.
- The new localStorage key is browser-scoped; different profiles or privacy modes will not share the saved width.
- Additional pointer-event logic must be maintained alongside future LiteGraph or Vue updates, but it remains isolated within the extension.
- Frame-throttled resizing keeps the interaction smooth at the cost of slightly delayed redraws when dragging extremely quickly, which we accept as a trade-off.
- The PrimeVue toolbar and minimap now float independently of the workflow canvas; if future ComfyUI updates rename these classes, we will need to refresh the tracked selectors.
+297 -2
View File
@@ -8,8 +8,16 @@ app.registerExtension({
console.info("[DualPanel] ★★★ setup() called ★★★");
const STYLE_ID = "dual-panel-subgraph-style";
const BODY_CLASS = "dual-panel-subgraph-open";
const BODY_RESIZING_CLASS = "dual-panel-subgraph-resizing";
const HOST_CLASS = "dual-panel-main-host";
const PANEL_CLASS = "dual-panel-subgraph";
const WIDTH_STORAGE_KEY = "dualPanel.subgraph.width.v1";
const MIN_PANEL_WIDTH = 320;
const MIN_MAIN_WIDTH = 360;
const FLOATING_UI_SELECTORS = [
".p-buttongroup.p-component.p-buttongroup-vertical",
".minimap-main-container"
];
installStyle();
@@ -19,6 +27,7 @@ app.registerExtension({
graphCanvas: null,
disposers: [],
mainHosts: [],
floatingToolbars: [],
};
function installStyle() {
@@ -37,6 +46,11 @@ app.registerExtension({
overflow: hidden;
}
body.${BODY_RESIZING_CLASS} {
cursor: ew-resize;
user-select: none;
}
.${PANEL_CLASS} {
position: fixed;
top: var(--dual-panel-top-offset, 0px);
@@ -50,6 +64,7 @@ app.registerExtension({
box-shadow: -24px 0 64px -48px rgba(0, 0, 0, 0.8);
z-index: 64;
transform: translateZ(0);
will-change: width;
}
.dual-panel-subgraph__header {
@@ -85,6 +100,16 @@ app.registerExtension({
transition: background 120ms ease, border-color 120ms ease;
}
.dual-panel-subgraph__resize-handle {
position: absolute;
top: 0;
left: -8px;
width: 16px;
height: 100%;
cursor: ew-resize;
z-index: 65;
}
.dual-panel-subgraph__close:hover {
background: rgba(255, 255, 255, 0.18);
border-color: rgba(255, 255, 255, 0.32);
@@ -110,10 +135,233 @@ app.registerExtension({
max-width: calc(100% - var(--dual-panel-subgraph-width));
transition: width 140ms ease, max-width 140ms ease;
}
body.${BODY_RESIZING_CLASS} .${PANEL_CLASS},
body.${BODY_RESIZING_CLASS} .${HOST_CLASS} {
transition: none !important;
}
.dual-panel-toolbar-fixed {
pointer-events: auto;
}
body.${BODY_CLASS} .dual-panel-toolbar-fixed {
position: fixed !important;
}
`;
document.head.appendChild(style);
}
function clampPanelWidth(width) {
const viewportWidth = Math.max(window.innerWidth || document.documentElement.clientWidth || 0, MIN_PANEL_WIDTH);
const maxWidth = Math.max(MIN_PANEL_WIDTH, viewportWidth - MIN_MAIN_WIDTH);
const safeWidth = Math.min(Math.max(width, MIN_PANEL_WIDTH), Math.max(MIN_PANEL_WIDTH, maxWidth));
return Math.round(safeWidth);
}
function loadStoredPanelWidth() {
try {
const raw = window.localStorage?.getItem(WIDTH_STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = Number.parseInt(raw, 10);
if (Number.isFinite(parsed)) {
return clampPanelWidth(parsed);
}
} catch (error) {
console.warn("[DualPanel] Failed to load stored panel width", error);
}
return null;
}
function persistPanelWidth(width) {
try {
window.localStorage?.setItem(WIDTH_STORAGE_KEY, String(clampPanelWidth(width)));
} catch (error) {
console.warn("[DualPanel] Failed to persist panel width", error);
}
}
function setPanelWidth(width, { panel, canvasHost, canvas, graphCanvas, persist = false } = {}) {
if (!panel) {
return;
}
const rounded = clampPanelWidth(width);
const widthPx = `${rounded}px`;
panel.style.width = widthPx;
document.documentElement.style.setProperty("--dual-panel-subgraph-width", widthPx);
updatePanelOffset(panel, canvasHost);
if (canvas && graphCanvas) {
handleResize(canvas, graphCanvas);
}
if (persist) {
persistPanelWidth(rounded);
}
}
function restoreFloatingToolbars() {
if (!state.floatingToolbars.length) {
return;
}
const entries = state.floatingToolbars.splice(0, state.floatingToolbars.length);
entries.forEach(({ element, previousInline }) => {
if (!element) {
return;
}
element.classList.remove("dual-panel-toolbar-fixed");
const { position, right, bottom, left, top, zIndex } = previousInline;
element.style.position = position;
element.style.right = right;
element.style.bottom = bottom;
element.style.left = left;
element.style.top = top;
element.style.zIndex = zIndex;
});
}
function pinFloatingToolbars() {
restoreFloatingToolbars();
const candidates = [];
const seen = new Set();
FLOATING_UI_SELECTORS.forEach((selector) => {
const elements = document.querySelectorAll(selector);
elements.forEach((element) => {
if (!element || seen.has(element)) {
return;
}
seen.add(element);
candidates.push(element);
});
});
const pinned = [];
candidates.forEach((element) => {
if (!element?.isConnected) {
return;
}
if (state.panel && state.panel.contains(element)) {
return;
}
const computed = window.getComputedStyle(element);
if (!computed) {
return;
}
if (computed.position !== "absolute" && computed.position !== "fixed") {
return;
}
const verticalAnchor = computed.bottom !== "auto" ? { prop: "bottom", value: computed.bottom } :
(computed.top !== "auto" ? { prop: "top", value: computed.top } : null);
const horizontalAnchor = computed.right !== "auto" ? { prop: "right", value: computed.right } :
(computed.left !== "auto" ? { prop: "left", value: computed.left } : null);
if (!verticalAnchor || !horizontalAnchor) {
return;
}
const previousInline = {
position: element.style.position,
right: element.style.right,
bottom: element.style.bottom,
left: element.style.left,
top: element.style.top,
zIndex: element.style.zIndex,
};
element.classList.add("dual-panel-toolbar-fixed");
element.style.position = "fixed";
element.style[verticalAnchor.prop] = verticalAnchor.value;
element.style[verticalAnchor.prop === "bottom" ? "top" : "bottom"] = "";
element.style[horizontalAnchor.prop] = horizontalAnchor.value;
element.style[horizontalAnchor.prop === "right" ? "left" : "right"] = "";
if (computed.zIndex === "auto") {
element.style.zIndex = "70";
} else {
element.style.zIndex = computed.zIndex;
}
pinned.push({ element, previousInline });
});
if (pinned.length) {
state.floatingToolbars = pinned;
state.disposers.push(restoreFloatingToolbars);
}
}
function setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle) {
if (!panel || !resizeHandle) {
return;
}
let resizeRafId = null;
const scheduleCanvasResize = () => {
if (!canvas || !graphCanvas) {
return;
}
if (resizeRafId !== null) {
return;
}
resizeRafId = window.requestAnimationFrame(() => {
resizeRafId = null;
handleResize(canvas, graphCanvas);
});
};
const pointerDown = (event) => {
const isPrimaryPointer = event.button === 0 || event.pointerType === "touch" || event.pointerType === "pen";
if (!isPrimaryPointer) {
return;
}
event.preventDefault();
const updateFromClientX = (clientX) => {
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || panel.getBoundingClientRect().right;
const desiredWidth = viewportWidth - clientX;
setPanelWidth(desiredWidth, { panel, canvasHost });
scheduleCanvasResize();
};
const releasePointer = (releaseEvent) => {
if (releaseEvent.pointerId !== event.pointerId) {
return;
}
resizeHandle.releasePointerCapture?.(event.pointerId);
resizeHandle.removeEventListener("pointermove", handlePointerMove);
resizeHandle.removeEventListener("pointerup", releasePointer);
resizeHandle.removeEventListener("pointercancel", releasePointer);
document.body.classList.remove(BODY_RESIZING_CLASS);
if (resizeRafId !== null) {
window.cancelAnimationFrame(resizeRafId);
resizeRafId = null;
}
const finalWidth = panel.getBoundingClientRect().width;
setPanelWidth(finalWidth, { panel, canvasHost, canvas, graphCanvas, persist: true });
};
const handlePointerMove = (moveEvent) => {
if (moveEvent.pointerId !== event.pointerId) {
return;
}
moveEvent.preventDefault();
updateFromClientX(moveEvent.clientX);
};
resizeHandle.setPointerCapture?.(event.pointerId);
document.body.classList.add(BODY_RESIZING_CLASS);
updateFromClientX(event.clientX);
resizeHandle.addEventListener("pointermove", handlePointerMove);
resizeHandle.addEventListener("pointerup", releasePointer);
resizeHandle.addEventListener("pointercancel", releasePointer);
};
resizeHandle.addEventListener("pointerdown", pointerDown);
state.disposers.push(() => {
document.body.classList.remove(BODY_RESIZING_CLASS);
if (resizeRafId !== null) {
window.cancelAnimationFrame(resizeRafId);
resizeRafId = null;
}
resizeHandle.removeEventListener("pointerdown", pointerDown);
});
}
function getMainCanvasElement() {
return app.canvas?.canvas ?? document.querySelector("#graph-canvas canvas");
}
@@ -169,6 +417,8 @@ app.registerExtension({
}
disposeListeners();
restoreFloatingToolbars();
document.body.classList.remove(BODY_RESIZING_CLASS);
if (state.graphCanvas?.graph) {
try {
@@ -317,10 +567,14 @@ app.registerExtension({
canvas.className = "litegraph";
canvasHost.appendChild(canvas);
const resizeHandle = document.createElement("div");
resizeHandle.className = "dual-panel-subgraph__resize-handle";
panel.appendChild(header);
panel.appendChild(resizeHandle);
panel.appendChild(canvasHost);
return { panel, canvasHost, canvas };
return { panel, canvasHost, canvas, resizeHandle };
}
function updatePanelOffset(panel, canvasHost) {
@@ -358,10 +612,28 @@ app.registerExtension({
ensureMainHosts();
const { panel, canvasHost, canvas } = createPanelElements(node);
const { panel, canvasHost, canvas, resizeHandle } = createPanelElements(node);
document.body.appendChild(panel);
document.body.classList.add(BODY_CLASS);
const storedWidth = loadStoredPanelWidth();
if (storedWidth) {
setPanelWidth(storedWidth, { panel, canvasHost, canvas });
}
const stopEventPropagation = (event) => {
event.stopPropagation();
};
const subgraphCanvasEvents = ["pointerdown", "pointerup", "dblclick", "contextmenu"];
subgraphCanvasEvents.forEach((evtName) => {
canvas.addEventListener(evtName, stopEventPropagation);
});
state.disposers.push(() => {
subgraphCanvasEvents.forEach((evtName) => {
canvas.removeEventListener(evtName, stopEventPropagation);
});
});
const subgraph = node.subgraph;
const graphCanvas = new LiteGraph.LGraphCanvas(canvas, null, {
skip_render: true,
@@ -374,10 +646,33 @@ app.registerExtension({
graphCanvas.subgraph = subgraph;
graphCanvas.startRendering?.();
const suppressedEmitEventSubtypes = new Set([
"empty-double-click",
"group-double-click",
"node-double-click"
]);
const originalEmitEvent = graphCanvas.emitEvent;
graphCanvas.emitEvent = function (event, ...args) {
const subtype = event?.subType;
if (subtype && suppressedEmitEventSubtypes.has(subtype)) {
return undefined;
}
if (typeof originalEmitEvent === "function") {
return originalEmitEvent.call(this, event, ...args);
}
return undefined;
};
state.disposers.push(() => {
graphCanvas.emitEvent = originalEmitEvent;
});
state.panel = panel;
state.node = node;
state.graphCanvas = graphCanvas;
setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle);
pinFloatingToolbars();
const panelResizeObserver = new ResizeObserver(() => {
syncLayoutFromPanel(panel, canvasHost);
handleResize(canvas, graphCanvas);