initial commit
This commit is contained in:
+37
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/cache
|
||||
@@ -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"
|
||||
@@ -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
@@ -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",
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user