From e024c58623b9af290b16befdf72b9e68976b5d5e Mon Sep 17 00:00:00 2001 From: nomadoor Date: Sun, 19 Oct 2025 13:01:04 +0900 Subject: [PATCH] refactor: enhance dual panel resizing logic and improve keyboard shortcut handling --- docs/adr/2025-10-19-dual-panel-resize.md | 13 +- js/dual_panel_subgraph.js | 308 ++++++++++++++++------- 2 files changed, 221 insertions(+), 100 deletions(-) diff --git a/docs/adr/2025-10-19-dual-panel-resize.md b/docs/adr/2025-10-19-dual-panel-resize.md index 9b233e7..009a147 100644 --- a/docs/adr/2025-10-19-dual-panel-resize.md +++ b/docs/adr/2025-10-19-dual-panel-resize.md @@ -11,13 +11,18 @@ - 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. -- Considered wiring `MutationObserver` + resize listeners so newly mounted minimap/zoom widgets stay pinned, but the naive prototype caused a feedback loop: pinning adjusts inline styles → observer fires → repin → layout thrashes. We postponed that approach until we can scope the observer to stable containers and temporarily pause it around our own mutations. +- Added scoped CSS so that, while the body carries `dual-panel-subgraph-open`, the minimap containerとズームボタン群の `right` を `calc(var(--dual-panel-subgraph-width) + 16px)` だけ押し戻し、UI をメインキャンバス側に寄せる(JavaScript でのスタイル書き換えは廃止)。 +- Considered wiring `MutationObserver` + resize listeners so newly mounted minimap/zoom widgets stay pinned, but the naive prototype caused a feedback loop: pinning adjusts inline styles -> observer fires -> repin -> layout thrashes. We postponed that approach until we can scope the observer to stable containers and temporarily pause it around our own mutations. +- Intercepted Delete/Backspace/`P` while the subgraph panel owns focus, routing those shortcuts directly to the secondary canvas and clearing the main-canvas selection up front so両キャンバスのノードが同時に削除される事故を防ぐ。 ## 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. +- 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. +- The PrimeVue toolbarとズーム UI は CSS で常にパネル幅ぶんシフトされるため、パネルの開閉・リサイズに連動して自然に位置が決まる。今後 ComfyUI 側のクラス名が変わった場合は、そのセレクタを更新する必要がある。 - Without scoped observers the pinned elements will still slide momentarily if the user toggles the minimap after the panel is open. Reintroducing observers requires the mitigations noted above to avoid another oscillation. + +## Follow-up Notes +- グローバルな keybinding サービスは依然として `app.canvas` を前提としているため、将来的には Pinia ストア/commandStore を介したセカンダリキャンバス切替を検討したい。 +- Any future attempt to keep minimap/zoom widgets pinned while toggling visibility should scope DOM observers to stable containers and pause them around our own mutations to avoid an infinite repin loop. diff --git a/js/dual_panel_subgraph.js b/js/dual_panel_subgraph.js index ed1bbbf..fb039d1 100644 --- a/js/dual_panel_subgraph.js +++ b/js/dual_panel_subgraph.js @@ -1,11 +1,11 @@ -import { app } from "../../scripts/app.js"; +import { app } from "../../scripts/app.js"; -console.info("[DualPanel] ★★★ Module loaded ★★★"); +console.info("[DualPanel] Module loaded"); app.registerExtension({ name: "DualPanel.SubgraphViewer", async setup(app) { - console.info("[DualPanel] ★★★ setup() called ★★★"); + 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"; @@ -14,11 +14,6 @@ app.registerExtension({ 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(); const state = { @@ -27,9 +22,12 @@ app.registerExtension({ graphCanvas: null, disposers: [], mainHosts: [], - floatingToolbars: [], + panelHasFocus: false, + rootCanvas: null, }; + registerShortcutHandlers(); + function installStyle() { if (document.getElementById(STYLE_ID)) { return; @@ -141,12 +139,34 @@ app.registerExtension({ transition: none !important; } - .dual-panel-toolbar-fixed { - pointer-events: auto; + body.${BODY_CLASS} .minimap-main-container, + body.${BODY_CLASS} .p-buttongroup.p-component.p-buttongroup-vertical, + body.${BODY_CLASS} .w-\\[250px\\].z-1300 { + position: fixed !important; + left: auto !important; } - body.${BODY_CLASS} .dual-panel-toolbar-fixed { - position: fixed !important; + body.${BODY_CLASS} .minimap-main-container { + bottom: 66px !important; + right: 0.5rem !important; + } + + body.${BODY_CLASS} .p-buttongroup.p-component.p-buttongroup-vertical { + bottom: 1rem !important; + right: 0.5rem !important; + } + + body.${BODY_CLASS} .w-\\[250px\\].z-1300 { + bottom: 66px !important; + right: 0.5rem !important; + } + + @media (min-width: 768px) { + body.${BODY_CLASS} .minimap-main-container, + body.${BODY_CLASS} .p-buttongroup.p-component.p-buttongroup-vertical, + body.${BODY_CLASS} .w-\\[250px\\].z-1300 { + right: 2.75rem !important; + } } `; document.head.appendChild(style); @@ -200,87 +220,162 @@ app.registerExtension({ } } - function restoreFloatingToolbars() { - if (!state.floatingToolbars.length) { - return; + function shouldIgnoreKeyEventTarget(target) { + if (!(target instanceof Element)) { + return false; } - 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; - }); + const tagName = target.tagName?.toLowerCase(); + if (tagName === "input" || tagName === "textarea") { + return true; + } + if (target.hasAttribute("contenteditable")) { + return true; + } + return Boolean(target.closest("[data-dual-panel-ignore-shortcuts]")); } - 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 clearCanvasSelection(canvas) { + if (!canvas) { + return; } + if (typeof canvas.deselectAll === "function") { + try { + canvas.deselectAll(); + } catch (error) { + console.warn("[DualPanel] Failed to deselect canvas items", error); + } + } + if (canvas.selectedItems?.clear) { + canvas.selectedItems.clear(); + } + if (Array.isArray(canvas.selected_nodes_list)) { + canvas.selected_nodes_list.length = 0; + } + if (canvas.selected_nodes) { + Object.keys(canvas.selected_nodes).forEach((key) => { + delete canvas.selected_nodes[key]; + }); + } + canvas.selected_node = null; + if (Array.isArray(canvas.selected_group_list)) { + canvas.selected_group_list.length = 0; + } + canvas.selected_group = null; + if (canvas.selected_link) { + canvas.selected_link = null; + } + } + + function getSelectedNodes(canvas) { + const nodes = []; + if (!canvas) { + return nodes; + } + if (canvas.selected_nodes) { + Object.keys(canvas.selected_nodes).forEach((key) => { + const node = canvas.selected_nodes[key]; + if (node && !nodes.includes(node)) { + nodes.push(node); + } + }); + } + if (Array.isArray(canvas.selected_nodes_list)) { + canvas.selected_nodes_list.forEach((node) => { + if (node && !nodes.includes(node)) { + nodes.push(node); + } + }); + } + return nodes; + } + + function subgraphHasSelection() { + const canvas = state.graphCanvas; + if (!canvas) { + return false; + } + if (canvas.selectedItems?.size) { + return true; + } + if (canvas.selected_group || canvas.selected_link) { + return true; + } + return getSelectedNodes(canvas).length > 0; + } + + function handleSubgraphDelete() { + const canvas = state.graphCanvas; + if (!canvas || !subgraphHasSelection()) { + return false; + } + if (typeof canvas.deleteSelected === "function") { + try { + canvas.deleteSelected(); + canvas.draw?.(true, true); + return true; + } catch (error) { + console.warn("[DualPanel] Failed to delete subgraph selection", error); + } + } + return false; + } + + function handleSubgraphPinToggle() { + const canvas = state.graphCanvas; + if (!canvas) { + return false; + } + const nodes = getSelectedNodes(canvas); + if (!nodes.length) { + return false; + } + let toggled = false; + nodes.forEach((node) => { + if (typeof node.pin === "function") { + try { + node.pin(!node.pinned); + toggled = true; + } catch (error) { + console.warn("[DualPanel] Failed to toggle pin on node", error); + } + } + }); + if (toggled) { + canvas.draw?.(true, true); + } + return toggled; + } + + function registerShortcutHandlers() { + const handler = (event) => { + if (!state.panelHasFocus || !state.graphCanvas) { + return; + } + if (shouldIgnoreKeyEventTarget(event.target)) { + return; + } + const code = event.code; + if (code === "Delete" || code === "Backspace") { + if (handleSubgraphDelete()) { + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + } + return; + } + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + if (code === "KeyP") { + if (handleSubgraphPinToggle()) { + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + } + } + }; + window.addEventListener("keydown", handler, { capture: true }); + state.disposers.push(() => window.removeEventListener("keydown", handler, { capture: true })); } function setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle) { @@ -406,8 +501,10 @@ app.registerExtension({ } function focusMainCanvas() { - if (app.canvas && LiteGraph.active_canvas !== app.canvas) { - LiteGraph.active_canvas = app.canvas; + state.panelHasFocus = false; + const root = app.canvas ?? state.rootCanvas; + if (root && LiteGraph.active_canvas !== root) { + LiteGraph.active_canvas = root; } } @@ -417,7 +514,6 @@ app.registerExtension({ } disposeListeners(); - restoreFloatingToolbars(); document.body.classList.remove(BODY_RESIZING_CLASS); if (state.graphCanvas?.graph) { @@ -447,6 +543,8 @@ app.registerExtension({ state.panel.remove(); state.panel = null; + state.panelHasFocus = false; + state.rootCanvas = null; resetMainHosts(); focusMainCanvas(); @@ -472,11 +570,22 @@ app.registerExtension({ return; } const focusSubgraph = () => { + state.panelHasFocus = true; + if (!state.rootCanvas) { + state.rootCanvas = app.canvas ?? null; + } + if (state.rootCanvas && state.rootCanvas !== graphCanvas) { + clearCanvasSelection(state.rootCanvas); + state.rootCanvas.draw?.(true, true); + } if (LiteGraph.active_canvas !== graphCanvas) { LiteGraph.active_canvas = graphCanvas; } }; - const focusRoot = () => focusMainCanvas(); + const focusRoot = () => { + state.panelHasFocus = false; + focusMainCanvas(); + }; canvasElement.addEventListener("mouseenter", focusSubgraph); canvasElement.addEventListener("mousedown", focusSubgraph); @@ -493,7 +602,13 @@ app.registerExtension({ canvasElement.removeEventListener("mouseenter", focusSubgraph); canvasElement.removeEventListener("mousedown", focusSubgraph); canvasElement.removeEventListener("touchstart", focusSubgraph, touchOptions); + canvasElement.removeEventListener("mouseleave", handleMouseLeave); }); + + function handleMouseLeave() { + state.panelHasFocus = false; + } + canvasElement.addEventListener("mouseleave", handleMouseLeave); } function registerEscapeListener() { @@ -611,6 +726,8 @@ app.registerExtension({ } ensureMainHosts(); + state.rootCanvas = app.canvas ?? null; + state.panelHasFocus = false; const { panel, canvasHost, canvas, resizeHandle } = createPanelElements(node); document.body.appendChild(panel); @@ -671,7 +788,6 @@ app.registerExtension({ state.graphCanvas = graphCanvas; setupPanelResizer(panel, canvasHost, canvas, graphCanvas, resizeHandle); - pinFloatingToolbars(); const panelResizeObserver = new ResizeObserver(() => { syncLayoutFromPanel(panel, canvasHost); @@ -769,7 +885,7 @@ app.registerExtension({ await patchOpenSubgraph(); - console.info("[DualPanel] ★★★ Patch complete ★★★"); + console.info("[DualPanel] Patch complete"); console.info("[DualPanel] DualPanel Subgraph Viewer extension ready"); }, });