From 7fb948bab3725b5f8e3bc30ee3beedeb82485102 Mon Sep 17 00:00:00 2001 From: nomadoor Date: Sun, 19 Oct 2025 14:46:39 +0900 Subject: [PATCH] refactor: update .gitignore, add LICENSE and README files, enhance dual panel functionality and shortcut handling --- .gitignore | 19 +- LICENSE.txt | 21 ++ README.md | 42 +++ README_ja.md | 40 +++ docs/adr/2025-10-19-dual-panel-resize.md | 27 +- js/dual_panel_subgraph.js | 360 +++++++++++++++-------- 6 files changed, 378 insertions(+), 131 deletions(-) create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 README_ja.md diff --git a/.gitignore b/.gitignore index 12f107b..ae4e86e 100644 --- a/.gitignore +++ b/.gitignore @@ -17,21 +17,38 @@ package-lock.json npm-debug.log* pnpm-debug.log* yarn-error.log* +.eslintcache +*.tsbuildinfo +*.d.ts # ---- Build outputs ---------------------------------------------------- dist/ build/ *.bundle.js +*.zip +*.tar +*.tar.gz + +# ---- ComfyUI cache and temporary data --------------------------------- +comfyui_temp/ +comfyui_cache/ +__outputs__/ +__inputs__/ # ---- Editor and OS metadata ------------------------------------------- .DS_Store Thumbs.db +ehthumbs.db +desktop.ini .idea/ .vscode/ +.history/ -# ---- Serena agent cache ------------------------------------------------ +# ---- Serena agent cache ----------------------------------------------- .serena/cache/ # ---- Misc -------------------------------------------------------------- *.log *.tmp +*.egg-info/ +MANIFEST diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8d6dc2e --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 ComfyUI Temporal Mask Tools Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f53805 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# ComfyUI Dual Panel Subgraph Viewer + +[![日本語版](https://img.shields.io/badge/README-日本語版-gray.svg)](README_ja.md) + + +A frontend extension for ComfyUI that allows you to open and edit subgraphs in a right-hand panel, instead of replacing the main canvas. + +--- + +> **Important** +> +> This extension is a **demonstration prototype**, created to explore the idea of viewing subgraphs in a dual-column layout. +> It is **highly experimental and not intended for practical use**. +> Stability and compatibility are not guaranteed, and crashes or rendering issues may occur. + +--- + +## Overview + +- Click the icon in the upper right corner of a Subgraph node, or double-click the node, to open the subgraph editor in a right-hand panel. +- Drag the edge of the panel to change its width. +- Click the `Close` button in the upper right corner of the panel to close it. + +--- + +## Technical Limitations and Known Issues + +- The double-click canvas menu uses the legacy implementation, as the Vue-based menu could not be integrated. +- Multi-text input fields (such as those in `CLIP Text Encode` nodes) are unavailable because the subgraph’s `LGraphCanvas` runs outside ComfyUI’s Vue component tree, preventing event binding and input initialization. +- Both the main and subgraph canvases can have nodes selected at the same time. When the mouse moves into the subgraph panel, selections in the main panel are cleared. +- Copy and paste shortcuts (`Ctrl + C / V`) do not work within the subgraph panel. +- The layout may momentarily break or render incorrectly while resizing the subgraph panel. +- Deleting a node inside a subgraph sometimes displays the message `Nothing Selected`. + +--- + +## Disclaimer + +This extension is an **experimental proof of concept**. +It may stop working with future versions of ComfyUI. +Use at your own risk and avoid deploying it in production environments. + diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..8a8093f --- /dev/null +++ b/README_ja.md @@ -0,0 +1,40 @@ +# ComfyUI Dual Panel Subgraph Viewer + +ComfyUIのフロントエンド拡張です。 +サブグラフを開く際にメインキャンバスを置き換えるのではなく、画面右側にパネルとして同時に表示・編集できるようにします。 + +--- + +> **重要** +> +> 本拡張は「サブグラフを2カラムで表示できたらどうか?」という発想を検証するための**デモンストレーション的実装**です。 +> 実験的であり、**安定性や実用性は一切保証されません**。 +> 動作が不安定になる、あるいはクラッシュする可能性があります。 + +--- + +## 概要 + +- サブグラフノード右上のアイコンをクリック、またはノードをダブルクリックすると、右側に編集パネルが展開されます。 +- パネル端をドラッグすることで幅を変更できます。 +- 右上の `Close` ボタンでパネルを閉じることができます。 + +--- + +## 技術的限界・既知の問題 + +- キャンバスをダブルクリックして開くメニューは、Vue版が利用できなかったためレガシーメニューのみ使用しています。 +- `CLIP Text Encode` ノードなどのマルチテキスト入力欄は、サブグラフ側では Vue コンポーネントが初期化されないため使用できません。 + (サブグラフキャンバスが ComfyUI のメイン Vue コンテキストと独立しており、入力イベントの再バインドが行えないため) +- メインパネルとサブパネルの両方でノードを同時に選択できてしまうため、マウスをサブパネルに移動するとメイン側の選択が解除されます。 +- サブグラフ上のノードに対して `Ctrl + C / V` が機能しません。 +- サブグラフパネルの幅を変更する際、描画が一時的に乱れることがあります。 +- サブグラフ内でノードを削除すると、`Nothing Selected` というメッセージが表示されることがあります。 + +--- + +## 注意事項 + +この拡張は**実験的な概念実証(proof of concept)**です。 +今後のComfyUIの更新によって動作しなくなる可能性があります。 +日常的な利用や商用環境での使用は推奨されません。 diff --git a/docs/adr/2025-10-19-dual-panel-resize.md b/docs/adr/2025-10-19-dual-panel-resize.md index 009a147..351ce7d 100644 --- a/docs/adr/2025-10-19-dual-panel-resize.md +++ b/docs/adr/2025-10-19-dual-panel-resize.md @@ -1,28 +1,27 @@ -# 2025-10-19 Dual Panel Resize Persistence +# 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. +- 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. +- 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. -- 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両キャンバスのノードが同時に削除される事故を防ぐ。 +- Throttled canvas resizes with requestAnimationFrame during pointer moves to keep drag interactions responsive while still redrawing at the next frame. +- Added scoped CSS so that, while the body carries dual-panel-subgraph-open, the minimap container, zoom button group, and zoom modal stay fixed at the viewport bottom-right using the standard Tailwind offsets (right-2, md:right-4, md:right-11), eliminating horizontal drift without any runtime style patching. +- When the side panel gains focus, we clear the main canvas selection and temporarily reassign app.canvas to the subgraph LGraphCanvas so the built-in keybindings (Delete/Backspace, Ctrl+C, Ctrl+V, P, etc.) operate on the secondary canvas while leaving the main canvas untouched. ## 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とズーム UI は CSS で常にパネル幅ぶんシフトされるため、パネルの開閉・リサイズに連動して自然に位置が決まる。今後 ComfyUI 側のクラス名が変わった場合は、そのセレクタを更新する必要がある。 +- The PrimeVue toolbar and zoom UI remain fixed via CSS, but if ComfyUI renames the underlying classes we will need to update the selectors. - 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 を介したセカンダリキャンバス切替を検討したい。 +- The global keybinding service still assumes a single app.canvas. For now we swap app.canvas when the subgraph panel has focus; a longer-term solution might integrate with the Pinia stores/command framework so multiple canvases can coexist cleanly. - 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 fb039d1..99fc542 100644 --- a/js/dual_panel_subgraph.js +++ b/js/dual_panel_subgraph.js @@ -162,8 +162,12 @@ app.registerExtension({ } @media (min-width: 768px) { - body.${BODY_CLASS} .minimap-main-container, - body.${BODY_CLASS} .p-buttongroup.p-component.p-buttongroup-vertical, + body.${BODY_CLASS} .minimap-main-container { + right: 2.75rem !important; + } + body.${BODY_CLASS} .p-buttongroup.p-component.p-buttongroup-vertical { + right: 1rem !important; + } body.${BODY_CLASS} .w-\\[250px\\].z-1300 { right: 2.75rem !important; } @@ -234,6 +238,232 @@ app.registerExtension({ return Boolean(target.closest("[data-dual-panel-ignore-shortcuts]")); } + function getSelectedNodes(canvas) { + const nodes = []; + if (!canvas) { + return nodes; + } + if (Array.isArray(canvas.selected_nodes_list)) { + canvas.selected_nodes_list.forEach((node) => { + if (node && !nodes.includes(node)) { + nodes.push(node); + } + }); + } + 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); + } + }); + } + return nodes; + } + + function getSelectedGroups(canvas) { + const groups = []; + if (!canvas) { + return groups; + } + if (Array.isArray(canvas.selected_group_list)) { + canvas.selected_group_list.forEach((group) => { + if (group) { + groups.push(group); + } + }); + } else if (canvas.selected_group) { + groups.push(canvas.selected_group); + } + return groups; + } + + function subgraphHasSelection() { + const canvas = state.graphCanvas; + if (!canvas) { + return false; + } + if (canvas.selectedItems?.size) { + return true; + } + if (canvas.selected_link) { + return true; + } + if (getSelectedGroups(canvas).length > 0) { + return true; + } + return getSelectedNodes(canvas).length > 0; + } + + function markCanvasDirty(canvas) { + if (!canvas) { + return; + } + if (typeof canvas.setDirty === "function") { + canvas.setDirty(true, true); + } else { + canvas.dirty_canvas = true; + canvas.dirty_bgcanvas = true; + } + } + + function withSafeCanvasOperation(canvas, operation) { + if (!canvas || typeof operation !== "function") { + return false; + } + const originalCheckPanels = canvas.checkPanels; + if (typeof originalCheckPanels === "function") { + canvas.checkPanels = () => undefined; + } + try { + return operation() ?? true; + } catch (error) { + console.warn("[DualPanel] Canvas operation failed", error); + return false; + } finally { + if (typeof originalCheckPanels === "function") { + canvas.checkPanels = originalCheckPanels; + } + } + } + + function handleSubgraphDelete() { + const canvas = state.graphCanvas; + if (!canvas || !subgraphHasSelection()) { + return false; + } + return withSafeCanvasOperation(canvas, () => { + if (typeof canvas.deleteSelected !== "function") { + return false; + } + const result = canvas.deleteSelected(); + markCanvasDirty(canvas); + canvas.draw?.(true, true); + return result !== 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) { + markCanvasDirty(canvas); + canvas.draw?.(true, true); + } + return toggled; + } + + function handleSubgraphCopy() { + const canvas = state.graphCanvas; + if (!canvas || !subgraphHasSelection()) { + return false; + } + return withSafeCanvasOperation(canvas, () => { + if (typeof canvas.copyToClipboard !== "function") { + return false; + } + canvas.copyToClipboard(); + return true; + }); + } + + function handleSubgraphCut() { + if (!handleSubgraphCopy()) { + return false; + } + return handleSubgraphDelete(); + } + + function handleSubgraphPaste(connectInputs) { + const canvas = state.graphCanvas; + if (!canvas || typeof canvas.pasteFromClipboard !== "function") { + return false; + } + return withSafeCanvasOperation(canvas, () => { + canvas.pasteFromClipboard({ connectInputs }); + markCanvasDirty(canvas); + canvas.draw?.(true, true); + return true; + }); + } + + function registerShortcutHandlers() { + const handler = (event) => { + if (!state.panelHasFocus || !state.graphCanvas) { + return; + } + if (shouldIgnoreKeyEventTarget(event.target)) { + return; + } + + const isMod = event.ctrlKey || event.metaKey; + const consume = () => { + event.preventDefault(); + event.stopImmediatePropagation?.(); + event.stopPropagation(); + }; + + if (isMod && !event.altKey) { + switch (event.code) { + case "KeyC": + if (handleSubgraphCopy()) { + consume(); + } + return; + case "KeyX": + if (handleSubgraphCut()) { + consume(); + } + return; + case "KeyV": + if (handleSubgraphPaste(event.shiftKey)) { + consume(); + } + return; + default: + return; + } + } + + if (!isMod && !event.altKey) { + switch (event.code) { + case "Delete": + case "Backspace": + if (handleSubgraphDelete()) { + consume(); + } + return; + case "KeyP": + if (handleSubgraphPinToggle()) { + consume(); + } + return; + default: + return; + } + } + }; + window.addEventListener("keydown", handler, { capture: true }); + state.disposers.push(() => window.removeEventListener("keydown", handler, { capture: true })); + } + function clearCanvasSelection(canvas) { if (!canvas) { return; @@ -266,118 +496,6 @@ app.registerExtension({ } } - 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) { if (!panel || !resizeHandle) { return; @@ -502,7 +620,10 @@ app.registerExtension({ function focusMainCanvas() { state.panelHasFocus = false; - const root = app.canvas ?? state.rootCanvas; + const root = state.rootCanvas ?? app.canvas; + if (root && app.canvas !== root) { + app.canvas = root; + } if (root && LiteGraph.active_canvas !== root) { LiteGraph.active_canvas = root; } @@ -544,10 +665,10 @@ app.registerExtension({ state.panel.remove(); state.panel = null; state.panelHasFocus = false; - state.rootCanvas = null; resetMainHosts(); focusMainCanvas(); + state.rootCanvas = null; console.debug(`[DualPanel] Panel closed (${reason})`); } @@ -578,6 +699,9 @@ app.registerExtension({ clearCanvasSelection(state.rootCanvas); state.rootCanvas.draw?.(true, true); } + if (app.canvas !== graphCanvas) { + app.canvas = graphCanvas; + } if (LiteGraph.active_canvas !== graphCanvas) { LiteGraph.active_canvas = graphCanvas; } @@ -728,6 +852,10 @@ app.registerExtension({ ensureMainHosts(); state.rootCanvas = app.canvas ?? null; state.panelHasFocus = false; + if (state.rootCanvas) { + clearCanvasSelection(state.rootCanvas); + state.rootCanvas.draw?.(true, true); + } const { panel, canvasHost, canvas, resizeHandle } = createPanelElements(node); document.body.appendChild(panel);