initial commit

This commit is contained in:
nomadoor
2025-10-18 23:32:08 +09:00
commit e008835801
8 changed files with 770 additions and 0 deletions
+37
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/cache
+71
View File
@@ -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"
+68
View File
@@ -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/<package>/`
- `__init__.py` must export `NODE_CLASS_MAPPINGS` (and optionally `NODE_DISPLAY_NAME_MAPPINGS`)
### JavaScript Extension (optional)
- Convention: add `<package>/js/` and export `WEB_DIRECTORY = "./js"` in `__init__.py`
- Files under `<package>/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)
+20
View File
@@ -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",
]
+27
View File
@@ -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.
@@ -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.
+464
View File
@@ -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");
},
});