From 47caeef292eae3460b4193787a33e00f1069c514 Mon Sep 17 00:00:00 2001 From: nomadoor Date: Sun, 19 Oct 2025 10:32:32 +0900 Subject: [PATCH] refactor: implement user-controlled resizing for dual panel subgraph viewer --- docs/adr/2025-10-18-dual-panel-initial.md | 8 + docs/adr/2025-10-19-dual-panel-resize.md | 21 ++ js/dual_panel_subgraph.js | 299 +++++++++++++++++++++- 3 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 docs/adr/2025-10-19-dual-panel-resize.md diff --git a/docs/adr/2025-10-18-dual-panel-initial.md b/docs/adr/2025-10-18-dual-panel-initial.md index 158e6f2..a3b1afa 100644 --- a/docs/adr/2025-10-18-dual-panel-initial.md +++ b/docs/adr/2025-10-18-dual-panel-initial.md @@ -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. diff --git a/docs/adr/2025-10-19-dual-panel-resize.md b/docs/adr/2025-10-19-dual-panel-resize.md new file mode 100644 index 0000000..a1ef758 --- /dev/null +++ b/docs/adr/2025-10-19-dual-panel-resize.md @@ -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. diff --git a/js/dual_panel_subgraph.js b/js/dual_panel_subgraph.js index 459b673..ed1bbbf 100644 --- a/js/dual_panel_subgraph.js +++ b/js/dual_panel_subgraph.js @@ -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);