From e00883580123c1916ed2b2b6fbcab21a0465b7db Mon Sep 17 00:00:00 2001 From: nomadoor Date: Sat, 18 Oct 2025 23:32:08 +0900 Subject: [PATCH] initial commit --- .gitignore | 37 ++ .serena/.gitignore | 1 + .serena/project.yml | 71 +++ AGENTS.md | 68 +++ __init__.py | 20 + docs/adr/2025-10-18-dual-panel-initial.md | 27 ++ docs/requirements/000-dual-panel-subgraph.md | 82 ++++ js/dual_panel_subgraph.js | 464 +++++++++++++++++++ 8 files changed, 770 insertions(+) create mode 100644 .gitignore create mode 100644 .serena/.gitignore create mode 100644 .serena/project.yml create mode 100644 AGENTS.md create mode 100644 __init__.py create mode 100644 docs/adr/2025-10-18-dual-panel-initial.md create mode 100644 docs/requirements/000-dual-panel-subgraph.md create mode 100644 js/dual_panel_subgraph.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12f107b --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# ---- Python artifacts ------------------------------------------------- +__pycache__/ +*.py[cod] +*.so +.python-version +.pytest_cache/ + +# ---- Virtual environments --------------------------------------------- +.venv/ +venv/ +env/ + +# ---- JavaScript / TypeScript tooling ---------------------------------- +node_modules/ +pnpm-lock.yaml +package-lock.json +npm-debug.log* +pnpm-debug.log* +yarn-error.log* + +# ---- Build outputs ---------------------------------------------------- +dist/ +build/ +*.bundle.js + +# ---- Editor and OS metadata ------------------------------------------- +.DS_Store +Thumbs.db +.idea/ +.vscode/ + +# ---- Serena agent cache ------------------------------------------------ +.serena/cache/ + +# ---- Misc -------------------------------------------------------------- +*.log +*.tmp diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..c4ac1c6 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,71 @@ +# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) +# * For C, use cpp +# * For JavaScript, use typescript +# Special requirements: +# * csharp: Requires the presence of a .sln file in the project folder. +language: python + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# whether to use the project's gitignore file to ignore files +# Added on 2025-04-07 +ignore_all_files_in_gitignore: true +# list of additional paths to ignore +# same syntax as gitignore, so you can use * and ** +# Was previously called `ignored_dirs`, please update your config if you are using that. +# Added (renamed) on 2025-04-07 +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +project_name: "ComfyUI-DualPanel-Subgraph-Viewer" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f667396 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,68 @@ +# AGENTS.md - ComfyUI Extension Protocol (v3) + +## 1. Scope +Defines the minimal behavioral protocol for autonomous agents (Codex / Serena / context7, etc.) to generate, validate, and maintain **ComfyUI v3 schema–compliant extensions** (Python nodes and JavaScript UI extensions) that target the current Vue/TypeScript frontend shipped via the `comfyui-frontend-package` pip distribution. + +## 2. Source of Truth +Use **official ComfyUI documentation only**; do not rely on undocumented assumptions. Cross-check both the backend docs and the new frontend repo / package notes when UI APIs are involved. + +- JavaScript extensions: `WEB_DIRECTORY`, auto-loading of `.js`, `app.registerExtension()` (Vue frontend compat notes) + https://docs.comfy.org/custom-nodes/js/javascript_overview +- Backend lifecycle: loading from `custom_nodes`, `NODE_CLASS_MAPPINGS`, optional `NODE_DISPLAY_NAME_MAPPINGS` + https://docs.comfy.org/custom-nodes/backend/lifecycle +- Custom node walkthrough (minimal Python + optional JS) + https://docs.comfy.org/custom-nodes/walkthrough +- v3 schema & migration guidance + https://docs.comfy.org/custom-nodes/v3_migration +- Core concepts overview for custom nodes + https://docs.comfy.org/development/core-concepts/custom-nodes +- Changelog (to confirm current v3 status) + https://docs.comfy.org/changelog + +## 3. Minimal Layout + +### Python Node (required) +- Location: `ComfyUI/custom_nodes//` +- `__init__.py` must export `NODE_CLASS_MAPPINGS` (and optionally `NODE_DISPLAY_NAME_MAPPINGS`) + +### JavaScript Extension (optional) +- Convention: add `/js/` and export `WEB_DIRECTORY = "./js"` in `__init__.py` +- Files under `/js/*.js` are auto-loaded; each must call `app.registerExtension({...})` + +(See the docs linked above for the exact mechanics.) + +## 4. Generation Rules +- Define nodes with the **v3 schema** (`define_schema()` with explicit I/O types, category, description) +- Python: single responsibility, deterministic, no unauthorized side effects or undeclared dependencies +- JavaScript: ES6+, build-free, no external libraries, public APIs only (do not modify core or private internals). Validate compatibility against the currently installed `comfyui-frontend-package` version; avoid depending on legacy DOM structures removed in the Vue rewrite. + +## 5. Workflow (Plan ↁEBuild ↁEValidate ↁETest ↁERecord) +1) **Plan**  EDescribe purpose, I/O, constraints, category in `docs/requirements/*.md` +2) **Build**  EImplement Python node (and JS extension if needed) +3) **Validate**  E`NODE_CLASS_MAPPINGS` present; `WEB_DIRECTORY` exported only if JS exists; schema integrity; safe imports +4) **Test**  ELoad locally in ComfyUI; confirm JS reloads without server restart +5) **Record**  EDocument decisions in `docs/adr/YYYY-MM-DD-*.md` + +## 6. Stop Rules (escalate before proceeding) +Stop and request clarification if: +- Requirements are ambiguous or contradictory +- Behavior diverges from the official docs +- Compatibility or security risks are suspected (e.g., XSS, arbitrary code execution) +- Confidence < 0.8 + +Escalation format: + Question: … + Context: … + Options: [A/B/…] + Recommendation: … (Confidence: 0.xx) + + +## 7. Pre-Commit Checklist +- [ ] `docs/requirements/*.md` exists and is current +- [ ] `__init__.py` exports `NODE_CLASS_MAPPINGS` +- [ ] If JS is included: `WEB_DIRECTORY = "./js"` is exported +- [ ] All nodes define a valid **v3 schema** +- [ ] No unsafe imports or undeclared dependencies +- [ ] No Python exceptions or JS console errors on load +- [ ] ADR updated under `docs/adr/` +- [ ] Confidence ≥ 0.8 (attach escalation log if lower) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..2a7cbd6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +""" +DualPanel Subgraph Viewer ComfyUI extension. + +Exports the required metadata for ComfyUI to discover the JavaScript frontend +extension that implements the dual panel subgraph viewer. No backend nodes are +defined at this time; the functionality is delivered entirely through the JS +layer as specified by docs.comfy.org. +""" + +from __future__ import annotations + +WEB_DIRECTORY = "./js" +NODE_CLASS_MAPPINGS: dict[str, type] = {} +NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {} + +__all__ = [ + "WEB_DIRECTORY", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", +] diff --git a/docs/adr/2025-10-18-dual-panel-initial.md b/docs/adr/2025-10-18-dual-panel-initial.md new file mode 100644 index 0000000..158e6f2 --- /dev/null +++ b/docs/adr/2025-10-18-dual-panel-initial.md @@ -0,0 +1,27 @@ +# 2025-10-18 — Dual Panel Subgraph Viewer + +## Context +- Requirement `000-dual-panel-subgraph` mandates a non-invasive JavaScript extension that opens subgraphs in a secondary panel while leaving the primary ComfyUI canvas usable. +- Official ComfyUI guidance for custom nodes (backend lifecycle) and JavaScript extensions requires exporting `NODE_CLASS_MAPPINGS` and `WEB_DIRECTORY` from `__init__.py`, with frontend logic loaded from `js/*.js`. +- The feature must work without altering core ComfyUI files and should gracefully handle focus, cleanup, and the absence of a subgraph. + +## Decision +- Provide a Python stub (`__init__.py`) that exposes `WEB_DIRECTORY = "./js"` and deliberately leaves `NODE_CLASS_MAPPINGS` empty because all functionality lives in the frontend. +- Implement `js/dual_panel_subgraph.js` as an `app.registerExtension` module that: + - Injects minimal CSS for a fixed right-side panel while keeping the main canvas interactive. + - Wraps `LiteGraph.Subgraph` double-click and expand behaviors to toggle the secondary panel instead of navigating away. + - Creates a dedicated `LGraphCanvas` bound to the node’s `subgraph`, with resize handling, focus switching, and cleanup on close or node removal. +- Add Escape-key, header button, and repeated trigger handling to close the panel and restore the main canvas state. + +## Consequences +- No backend node schema is exposed yet; future Python nodes can be added without changing the JS extension contract. +- Overlay approach avoids DOM restructuring but relies on LiteGraph APIs remaining stable; if ComfyUI updates those APIs we may need to revisit the wrappers. +- The extension assumes the browser supports `ResizeObserver`; for older browsers we would need a polyfill or fallback. + +## Verification & Iterations (2025-10-18) +- **Vue frontend integration:** Reworked the extension to import `{ app }` from the packaged frontend and patch `LGraphCanvas.openSubgraph` rather than relying on legacy DOM double-click handlers. This resolved the `ReferenceError: app is not defined` thrown when the new Vue UI attempted to load the script. +- **Canvas bootstrap stability:** Initial attempts to instantiate `LGraphCanvas` with the subgraph object triggered `dispatchEvent` errors when ComfyUI reused canvas elements. Updated the panel creation flow to create an empty canvas, call `setGraph(subgraph)`, and start rendering explicitly. +- **DOM host tracking:** Opening additional workflow tabs replaced the underlying canvas container, causing stale padding classes and layout breakage. `ensureMainHosts()` now validates cached host elements with `isConnected`, resets stale references, and rebuilds the host list from the current canvas hierarchy. +- **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. diff --git a/docs/requirements/000-dual-panel-subgraph.md b/docs/requirements/000-dual-panel-subgraph.md new file mode 100644 index 0000000..f4e96f7 --- /dev/null +++ b/docs/requirements/000-dual-panel-subgraph.md @@ -0,0 +1,82 @@ +# Feature Requirements: DualPanel Subgraph Viewer + +**ID:** `000-dual-panel-subgraph` +**Status:** Draft +**Updated:** 2025-10-18 + +--- + +## 1. Purpose +Enable users to open and edit **Subgraph** nodes in a secondary side panel whenever a subgraph is expanded (triggered by **double-clicking the node** or **clicking its expand icon**) without touching ComfyUI core frontend files. The implementation must stay compatible with the Vue/TypeScript frontend shipped via the `comfyui-frontend-package` pip distribution (verify baseline version during implementation). + +--- + +## 2. Goals +- Display the subgraph editor on the right side of the screen (dual-panel layout). +- Support both expansion triggers: + - Node double-click. + - Click on the Subgraph expand icon (ComfyUI's built-in expand control). +- Allow full node interaction (pan, zoom, connect, delete, add) within the side panel. +- Maintain correct focus switching between main and subgraph canvases. +- Cleanly remove the panel and restore state on close. +- Implement entirely as a **non-invasive** JS extension with a minimal Python stub, targeting the modern Vue frontend. + +--- + +## 3. UI Concept + +- Right half: displays subgraph content in a secondary `LGraphCanvas`. +- Left half: remains the main workflow canvas. +- Only one panel may be open at a time (v1 limitation). + +--- + +## 4. Basic Behavior + +### Open +- **Triggers:** + - Double-click a Subgraph node + - Click the node's expand icon +- **Action:** + - Create a right-side panel with a new `LGraphCanvas` rendering `node.subgraph` + - Keep the main canvas visible and interactive + +### Edit +- Inside the panel: pan, zoom, move, connect, delete, and add nodes. +- `LiteGraph.active_canvas` switches automatically on mouse enter/leave. + +### Close +- Triggered by a close button, Escape key, or second double-click/icon click. +- Destroys the panel, clears canvas, removes event listeners, and restores main focus. + +--- + +## 5. Implementation Notes + +### Python (`__init__.py`) +``` +WEB_DIRECTORY = "./js" +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} +JavaScript (js/dual_panel.js) +Register via app.registerExtension("DualPanel.SubgraphViewer"). +``` + +- Integrate with the Vue-based workflow navigation (for example, patch `LGraphCanvas.openSubgraph` or use documented services) instead of relying on legacy DOM double-click listeners, because the Vue wrapper consumes those events. +- Create and destroy a div panel containing an independent `LGraphCanvas`. +- Handle cleanup and focus management safely. +- Document any minimum `comfyui-frontend-package` (Vue frontend) version in the ADR when new APIs are required. +- Avoid touching packaged frontend assets; ship changes solely through the extension auto-loader. + +## 6. Edge Cases +- Node lacks subgraph -> log warning, do nothing. +- Opening a new panel closes any existing one. +- Handle resize, rapid toggling, and LiteGraph version mismatches gracefully. + +## 7. Acceptance Criteria + +- Subgraph opens via double-click or expand icon. +- User can edit nodes normally inside the side panel. +- Focus switching works (main <-> panel). +- Panel closes cleanly, no memory leaks or console errors. +- Works on latest Chrome, Firefox, Edge. diff --git a/js/dual_panel_subgraph.js b/js/dual_panel_subgraph.js new file mode 100644 index 0000000..75234fd --- /dev/null +++ b/js/dual_panel_subgraph.js @@ -0,0 +1,464 @@ +import { app } from "../../scripts/app.js"; + +console.info("[DualPanel] ★★★ Module loaded ★★★"); + +app.registerExtension({ + name: "DualPanel.SubgraphViewer", + async setup(app) { + console.info("[DualPanel] ★★★ setup() called ★★★"); + const STYLE_ID = "dual-panel-subgraph-style"; + const BODY_CLASS = "dual-panel-subgraph-open"; + const HOST_CLASS = "dual-panel-main-host"; + const PANEL_CLASS = "dual-panel-subgraph"; + + installStyle(); + + const state = { + panel: null, + node: null, + graphCanvas: null, + disposers: [], + mainHosts: [], + }; + + function installStyle() { + if (document.getElementById(STYLE_ID)) { + return; + } + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + :root { + --dual-panel-subgraph-width: min(50vw, 680px); + } + + body.${BODY_CLASS} { + overflow: hidden; + } + + .${PANEL_CLASS} { + position: fixed; + top: 0; + right: 0; + width: var(--dual-panel-subgraph-width); + height: 100vh; + display: flex; + flex-direction: column; + background: var(--comfy-menu-bg, #1b1b1f); + border-left: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: -24px 0 64px -48px rgba(0, 0, 0, 0.8); + z-index: 64; + transform: translateZ(0); + } + + .dual-panel-subgraph__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + gap: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.35); + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .dual-panel-subgraph__title { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .dual-panel-subgraph__close { + appearance: none; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 4px; + padding: 4px 10px; + font-size: 12px; + background: rgba(255, 255, 255, 0.08); + color: inherit; + cursor: pointer; + transition: background 120ms ease, border-color 120ms ease; + } + + .dual-panel-subgraph__close:hover { + background: rgba(255, 255, 255, 0.18); + border-color: rgba(255, 255, 255, 0.32); + } + + .dual-panel-subgraph__canvas { + flex: 1; + position: relative; + overflow: hidden; + background: var(--comfy-menu-bg, #1b1b1f); + } + + .dual-panel-subgraph__canvas canvas { + width: 100%; + height: 100%; + display: block; + } + + body.${BODY_CLASS} .${HOST_CLASS} { + box-sizing: border-box; + padding-right: var(--dual-panel-subgraph-width); + transition: padding-right 140ms ease; + } + `; + document.head.appendChild(style); + } + + function getMainCanvasElement() { + return app.canvas?.canvas ?? document.querySelector("#graph-canvas canvas"); + } + + function ensureMainHosts() { + const currentCanvas = getMainCanvasElement(); + const isHostValid = (el) => el && el.isConnected && el.classList.contains(HOST_CLASS); + if (state.mainHosts.length) { + if (state.mainHosts.every(isHostValid)) { + const canvasParent = currentCanvas?.parentElement; + if (canvasParent && state.mainHosts.includes(canvasParent)) { + return; + } + } + resetMainHosts(); + } + if (!currentCanvas) { + return; + } + const hosts = []; + let current = currentCanvas.parentElement; + for (let depth = 0; current && current !== document.body && depth < 3; depth += 1) { + if (!current.classList.contains(HOST_CLASS)) { + current.classList.add(HOST_CLASS); + hosts.push(current); + } + current = current.parentElement; + } + state.mainHosts = hosts; + } + + function resetMainHosts() { + state.mainHosts.forEach((element) => { + element.classList.remove(HOST_CLASS); + }); + state.mainHosts = []; + } + + function disposeListeners() { + while (state.disposers.length) { + const dispose = state.disposers.pop(); + try { + dispose(); + } catch (error) { + console.warn("[DualPanel] Failed to dispose resource", error); + } + } + } + + function focusMainCanvas() { + if (app.canvas && LiteGraph.active_canvas !== app.canvas) { + LiteGraph.active_canvas = app.canvas; + } + } + + function closePanel(reason = "manual") { + if (!state.panel) { + return; + } + + disposeListeners(); + + if (state.graphCanvas?.graph) { + try { + state.graphCanvas.graph.detachCanvas(state.graphCanvas); + } catch (error) { + console.warn("[DualPanel] Failed to detach subgraph canvas", error); + } + } + + state.graphCanvas?.stopRendering?.(); + + if (state.graphCanvas?.destroy) { + try { + state.graphCanvas.destroy(); + } catch (error) { + console.warn("[DualPanel] Failed to destroy subgraph canvas", error); + } + } + + state.graphCanvas = null; + state.node = null; + + document.body.classList.remove(BODY_CLASS); + document.documentElement.style.removeProperty("--dual-panel-subgraph-width"); + + state.panel.remove(); + state.panel = null; + + resetMainHosts(); + focusMainCanvas(); + + console.debug(`[DualPanel] Panel closed (${reason})`); + } + + function handleResize(canvasElement, graphCanvas) { + if (!canvasElement || !graphCanvas) { + return; + } + const parent = canvasElement.parentElement || canvasElement; + const rect = parent.getBoundingClientRect(); + const devicePixelRatio = window.devicePixelRatio || 1; + canvasElement.width = Math.max(1, Math.floor(rect.width * devicePixelRatio)); + canvasElement.height = Math.max(1, Math.floor(rect.height * devicePixelRatio)); + graphCanvas.resize(); + graphCanvas.draw(true, true); + } + + function attachFocusHandlers(canvasElement, graphCanvas) { + if (!canvasElement || !graphCanvas) { + return; + } + const focusSubgraph = () => { + if (LiteGraph.active_canvas !== graphCanvas) { + LiteGraph.active_canvas = graphCanvas; + } + }; + const focusRoot = () => focusMainCanvas(); + + canvasElement.addEventListener("mouseenter", focusSubgraph); + canvasElement.addEventListener("mousedown", focusSubgraph); + const touchOptions = { passive: true }; + canvasElement.addEventListener("touchstart", focusSubgraph, touchOptions); + + const mainCanvas = getMainCanvasElement(); + if (mainCanvas) { + mainCanvas.addEventListener("mouseenter", focusRoot); + state.disposers.push(() => mainCanvas.removeEventListener("mouseenter", focusRoot)); + } + + state.disposers.push(() => { + canvasElement.removeEventListener("mouseenter", focusSubgraph); + canvasElement.removeEventListener("mousedown", focusSubgraph); + canvasElement.removeEventListener("touchstart", focusSubgraph, touchOptions); + }); + } + + function registerEscapeListener() { + const handler = (event) => { + if (event.key === "Escape") { + closePanel("escape"); + } + }; + window.addEventListener("keydown", handler, { capture: true }); + state.disposers.push(() => window.removeEventListener("keydown", handler, { capture: true })); + } + + function registerGraphRemovalWatcher(node) { + const graph = node?.graph; + if (!graph) { + return; + } + const original = graph.onNodeRemoved; + graph.onNodeRemoved = function patchedOnNodeRemoved(removedNode) { + if (removedNode === node) { + closePanel("node-removed"); + } + if (typeof original === "function") { + return original.apply(this, arguments); + } + return undefined; + }; + state.disposers.push(() => { + graph.onNodeRemoved = original; + }); + } + + function registerNodeRemovalHook(node) { + const originalOnRemoved = node.onRemoved; + node.onRemoved = function patchedOnRemoved() { + closePanel("node-removed"); + if (typeof originalOnRemoved === "function") { + return originalOnRemoved.apply(this, arguments); + } + return undefined; + }; + state.disposers.push(() => { + node.onRemoved = originalOnRemoved; + }); + } + + function createPanelElements(node) { + const panel = document.createElement("aside"); + panel.className = PANEL_CLASS; + + const header = document.createElement("div"); + header.className = "dual-panel-subgraph__header"; + + const title = document.createElement("div"); + title.className = "dual-panel-subgraph__title"; + title.textContent = node?.title || node?.type || "Subgraph"; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.className = "dual-panel-subgraph__close"; + closeButton.textContent = "Close"; + closeButton.addEventListener("click", () => closePanel("button")); + + header.appendChild(title); + header.appendChild(closeButton); + + const canvasHost = document.createElement("div"); + canvasHost.className = "dual-panel-subgraph__canvas"; + + const canvas = document.createElement("canvas"); + canvas.className = "litegraph"; + canvasHost.appendChild(canvas); + + panel.appendChild(header); + panel.appendChild(canvasHost); + + return { panel, canvasHost, canvas }; + } + + function syncLayoutFromPanel(panel) { + const width = panel.getBoundingClientRect().width; + if (width > 0) { + document.documentElement.style.setProperty("--dual-panel-subgraph-width", `${Math.round(width)}px`); + } + } + + function openPanel(node) { + closePanel("replacement"); + + if (!node?.subgraph) { + console.warn("[DualPanel] Attempted to open panel for node without subgraph", node); + return false; + } + + ensureMainHosts(); + + const { panel, canvasHost, canvas } = createPanelElements(node); + document.body.appendChild(panel); + document.body.classList.add(BODY_CLASS); + + const subgraph = node.subgraph; + const graphCanvas = new LiteGraph.LGraphCanvas(canvas, null, { + skip_render: true, + }); + graphCanvas.allow_dragcanvas = true; + graphCanvas.allow_dragnodes = true; + graphCanvas.allow_searchbox = true; + + graphCanvas.setGraph(subgraph); + graphCanvas.subgraph = subgraph; + graphCanvas.startRendering?.(); + + state.panel = panel; + state.node = node; + state.graphCanvas = graphCanvas; + + const panelResizeObserver = new ResizeObserver(() => { + syncLayoutFromPanel(panel); + handleResize(canvas, graphCanvas); + }); + panelResizeObserver.observe(panel); + state.disposers.push(() => panelResizeObserver.disconnect()); + + const canvasResizeObserver = new ResizeObserver(() => handleResize(canvas, graphCanvas)); + canvasResizeObserver.observe(canvasHost); + state.disposers.push(() => canvasResizeObserver.disconnect()); + + syncLayoutFromPanel(panel); + handleResize(canvas, graphCanvas); + + attachFocusHandlers(canvas, graphCanvas); + registerEscapeListener(); + registerGraphRemovalWatcher(node); + registerNodeRemovalHook(node); + + LiteGraph.active_canvas = graphCanvas; + + state.disposers.push(() => { + canvasHost.replaceChildren(); + }); + + console.debug("[DualPanel] Subgraph panel opened", { + node_id: node.id, + node_title: node.title, + }); + + return true; + } + + function isSubgraphNode(node) { + if (!node) { + return false; + } + if (typeof node.isSubgraphNode === "function") { + try { + return Boolean(node.isSubgraphNode()); + } catch (error) { + console.warn("[DualPanel] node.isSubgraphNode() threw", error); + } + } + return Boolean(node.subgraph); + } + + function togglePanel(node) { + if (!isSubgraphNode(node)) { + return false; + } + if (state.panel && state.node === node) { + closePanel("toggle"); + return true; + } + return openPanel(node); + } + + async function waitForLGraphCanvasPrototype(maxAttempts = 200, delayMs = 50) { + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const prototype = LiteGraph?.LGraphCanvas?.prototype; + if (prototype) { + return prototype; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return null; + } + + async function patchOpenSubgraph() { + const prototype = await waitForLGraphCanvasPrototype(); + if (!prototype) { + console.warn("[DualPanel] Unable to locate LiteGraph canvas prototype."); + return; + } + if (prototype.__dualPanelPatched) { + return; + } + + const originalOpenSubgraph = prototype.openSubgraph; + prototype.openSubgraph = function dualPanelOpenSubgraph(subgraph, fromNode, ...rest) { + const candidateNode = fromNode || subgraph?.owner_node || subgraph?.parent_node; + if (candidateNode && togglePanel(candidateNode)) { + return; + } + if (typeof originalOpenSubgraph === "function") { + return originalOpenSubgraph.call(this, subgraph, fromNode, ...rest); + } + return undefined; + }; + + prototype.__dualPanelPatched = true; + } + + await patchOpenSubgraph(); + + console.info("[DualPanel] ★★★ Patch complete ★★★"); + console.info("[DualPanel] DualPanel Subgraph Viewer extension ready"); + }, +});