diff --git a/.gitattributes b/.gitattributes
index 749554ee1..de05efbf4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -9,6 +9,7 @@
*.mts text eol=lf
*.ts text eol=lf
*.vue text eol=lf
+*.yaml text eol=lf
# Generated files
src/types/comfyRegistryTypes.ts linguist-generated=true
diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml
index fec0c7a0e..e87dbd057 100644
--- a/.github/workflows/test-ui.yaml
+++ b/.github/workflows/test-ui.yaml
@@ -12,6 +12,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
+ playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v4
@@ -66,6 +67,13 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
+ - name: Playwright Version
+ id: playwright-version
+ run: |
+ PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --json | jq --raw-output '.[0].devDependencies["@playwright/test"].version')
+ echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
+ working-directory: ComfyUI_frontend
+
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
@@ -116,16 +124,30 @@ jobs:
pip install wait-for-it
working-directory: ComfyUI
+
+ - name: Cache Playwright Browsers
+ uses: actions/cache@v4
+ id: cache-playwright-browsers
+ with:
+ path: '~/.cache/ms-playwright'
+ key: '${{ runner.os }}-playwright-browsers-${{ needs.setup.outputs.playwright-version }}'
+
+ - name: Install Playwright Browsers
+ if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
+ run: pnpm exec playwright install chromium --with-deps
+ working-directory: ComfyUI_frontend
+
+ - name: Install Playwright Browsers (operating system dependencies)
+ if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
+ run: pnpm exec playwright install-deps
+ working-directory: ComfyUI_frontend
+
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- - name: Install Playwright Browsers
- run: npx playwright install chromium --with-deps
- working-directory: ComfyUI_frontend
-
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
@@ -162,7 +184,7 @@ jobs:
retention-days: 1
playwright-tests:
- # Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
+ # Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
needs: setup
runs-on: ubuntu-latest
@@ -203,16 +225,29 @@ jobs:
pip install wait-for-it
working-directory: ComfyUI
+ - name: Cache Playwright Browsers
+ uses: actions/cache@v4
+ id: cache-playwright-browsers
+ with:
+ path: '~/.cache/ms-playwright'
+ key: '${{ runner.os }}-playwright-browsers-${{ needs.setup.outputs.playwright-version }}'
+
+ - name: Install Playwright Browsers
+ if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
+ run: pnpm exec playwright install chromium --with-deps
+ working-directory: ComfyUI_frontend
+
+ - name: Install Playwright Browsers (operating system dependencies)
+ if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
+ run: pnpm exec playwright install-deps
+ working-directory: ComfyUI_frontend
+
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- - name: Install Playwright Browsers
- run: npx playwright install chromium --with-deps
- working-directory: ComfyUI_frontend
-
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
@@ -295,4 +330,4 @@ jobs:
with:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
- retention-days: 30
\ No newline at end of file
+ retention-days: 30
diff --git a/.github/workflows/update-electron-types.yaml b/.github/workflows/update-electron-types.yaml
index 04d8cc436..0dfcdea34 100644
--- a/.github/workflows/update-electron-types.yaml
+++ b/.github/workflows/update-electron-types.yaml
@@ -35,12 +35,12 @@ jobs:
electron-types-tools-cache-${{ runner.os }}-
- name: Update electron types
- run: pnpm install @comfyorg/comfyui-electron-types@latest
+ run: pnpm install --workspace-root @comfyorg/comfyui-electron-types@latest
- name: Get new version
id: get-version
run: |
- NEW_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('./pnpm-lock.yaml')).packages['node_modules/@comfyorg/comfyui-electron-types'].version)")
+ NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
diff --git a/.vscode/tailwind.json b/.vscode/tailwind.json
index 96a1f5797..4efd1d966 100644
--- a/.vscode/tailwind.json
+++ b/.vscode/tailwind.json
@@ -2,12 +2,32 @@
"version": 1.1,
"atDirectives": [
{
- "name": "@tailwind",
- "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
+ "name": "@import",
+ "description": "Use the `@import` directive to inline CSS files, including Tailwind itself, into your stylesheet.",
"references": [
{
"name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
+ "url": "https://tailwindcss.com/docs/functions-and-directives#import"
+ }
+ ]
+ },
+ {
+ "name": "@theme",
+ "description": "Use the `@theme` directive to define custom design tokens like fonts, colors, and breakpoints.",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url": "https://tailwindcss.com/docs/functions-and-directives#theme"
+ }
+ ]
+ },
+ {
+ "name": "@layer",
+ "description": "Use the `@layer` directive inside `@theme` to organize custom styles into different layers like `base`, `components`, and `utilities`.",
+ "references": [
+ {
+ "name": "Tailwind Documentation",
+ "url": "https://tailwindcss.com/docs/functions-and-directives#layer"
}
]
},
@@ -22,32 +42,32 @@
]
},
{
- "name": "@responsive",
- "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
+ "name": "@config",
+ "description": "Use the `@config` directive to load a legacy JavaScript-based Tailwind configuration file.",
"references": [
{
"name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
+ "url": "https://tailwindcss.com/docs/functions-and-directives#config"
}
]
},
{
- "name": "@screen",
- "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
+ "name": "@reference",
+ "description": "Use the `@reference` directive to import theme variables, custom utilities, and custom variants from other files without duplicating CSS.",
"references": [
{
"name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#screen"
+ "url": "https://tailwindcss.com/docs/functions-and-directives#reference"
}
]
},
{
- "name": "@variants",
- "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
+ "name": "@plugin",
+ "description": "Use the `@plugin` directive to load a legacy JavaScript-based Tailwind plugin.",
"references": [
{
"name": "Tailwind Documentation",
- "url": "https://tailwindcss.com/docs/functions-and-directives#variants"
+ "url": "https://tailwindcss.com/docs/functions-and-directives#plugin"
}
]
}
diff --git a/CLAUDE.md b/CLAUDE.md
index 2ac2ab06e..68be11a12 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -127,3 +127,6 @@ const value = api.getServerFeature('config_name', defaultValue) // Get config
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
+- NEVER use `dark:` prefix - always use `dark-theme:` for dark mode styles, for example: `dark-theme:text-white dark-theme:bg-black`
+- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `
`
+
diff --git a/browser_tests/fixtures/UserSelectPage.ts b/browser_tests/fixtures/UserSelectPage.ts
index c0d4c90d3..62a961375 100644
--- a/browser_tests/fixtures/UserSelectPage.ts
+++ b/browser_tests/fixtures/UserSelectPage.ts
@@ -1,5 +1,4 @@
-import { test as base } from '@playwright/test'
-import { Page } from 'playwright'
+import { Page, test as base } from '@playwright/test'
export class UserSelectPage {
constructor(
diff --git a/browser_tests/fixtures/utils/taskHistory.ts b/browser_tests/fixtures/utils/taskHistory.ts
index 2c42c8492..01dfb1a4a 100644
--- a/browser_tests/fixtures/utils/taskHistory.ts
+++ b/browser_tests/fixtures/utils/taskHistory.ts
@@ -1,7 +1,7 @@
+import type { Request, Route } from '@playwright/test'
import _ from 'es-toolkit/compat'
import fs from 'fs'
import path from 'path'
-import type { Request, Route } from 'playwright'
import { v4 as uuidv4 } from 'uuid'
import type {
diff --git a/browser_tests/fixtures/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts
new file mode 100644
index 000000000..5c4541b92
--- /dev/null
+++ b/browser_tests/fixtures/utils/vueNodeFixtures.ts
@@ -0,0 +1,131 @@
+import type { Locator, Page } from '@playwright/test'
+
+import type { NodeReference } from './litegraphUtils'
+
+/**
+ * VueNodeFixture provides Vue-specific testing utilities for interacting with
+ * Vue node components. It bridges the gap between litegraph node references
+ * and Vue UI components.
+ */
+export class VueNodeFixture {
+ constructor(
+ private readonly nodeRef: NodeReference,
+ private readonly page: Page
+ ) {}
+
+ /**
+ * Get the node's header element using data-testid
+ */
+ async getHeader(): Promise {
+ const nodeId = this.nodeRef.id
+ return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
+ }
+
+ /**
+ * Get the node's title element
+ */
+ async getTitleElement(): Promise {
+ const header = await this.getHeader()
+ return header.locator('[data-testid="node-title"]')
+ }
+
+ /**
+ * Get the current title text
+ */
+ async getTitle(): Promise {
+ const titleElement = await this.getTitleElement()
+ return (await titleElement.textContent()) || ''
+ }
+
+ /**
+ * Set a new title by double-clicking and entering text
+ */
+ async setTitle(newTitle: string): Promise {
+ const titleElement = await this.getTitleElement()
+ await titleElement.dblclick()
+
+ const input = (await this.getHeader()).locator(
+ '[data-testid="node-title-input"]'
+ )
+ await input.fill(newTitle)
+ await input.press('Enter')
+ }
+
+ /**
+ * Cancel title editing
+ */
+ async cancelTitleEdit(): Promise {
+ const titleElement = await this.getTitleElement()
+ await titleElement.dblclick()
+
+ const input = (await this.getHeader()).locator(
+ '[data-testid="node-title-input"]'
+ )
+ await input.press('Escape')
+ }
+
+ /**
+ * Check if the title is currently being edited
+ */
+ async isEditingTitle(): Promise {
+ const header = await this.getHeader()
+ const input = header.locator('[data-testid="node-title-input"]')
+ return await input.isVisible()
+ }
+
+ /**
+ * Get the collapse/expand button
+ */
+ async getCollapseButton(): Promise {
+ const header = await this.getHeader()
+ return header.locator('[data-testid="node-collapse-button"]')
+ }
+
+ /**
+ * Toggle the node's collapsed state
+ */
+ async toggleCollapse(): Promise {
+ const button = await this.getCollapseButton()
+ await button.click()
+ }
+
+ /**
+ * Get the collapse icon element
+ */
+ async getCollapseIcon(): Promise {
+ const button = await this.getCollapseButton()
+ return button.locator('i')
+ }
+
+ /**
+ * Get the collapse icon's CSS classes
+ */
+ async getCollapseIconClass(): Promise {
+ const icon = await this.getCollapseIcon()
+ return (await icon.getAttribute('class')) || ''
+ }
+
+ /**
+ * Check if the collapse button is visible
+ */
+ async isCollapseButtonVisible(): Promise {
+ const button = await this.getCollapseButton()
+ return await button.isVisible()
+ }
+
+ /**
+ * Get the node's body/content element
+ */
+ async getBody(): Promise {
+ const nodeId = this.nodeRef.id
+ return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
+ }
+
+ /**
+ * Check if the node body is visible (not collapsed)
+ */
+ async isBodyVisible(): Promise {
+ const body = await this.getBody()
+ return await body.isVisible()
+ }
+}
diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png
index 919ef3fcf..e638f12ce 100644
Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-nodes-border-chromium-linux.png differ
diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png
index 919ef3fcf..e638f12ce 100644
Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-multiple-selections-border-chromium-linux.png differ
diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png
index 0f5bf8c84..3cec1c675 100644
Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-node-no-border-chromium-linux.png differ
diff --git a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-selection-no-border-chromium-linux.png b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-selection-no-border-chromium-linux.png
index 0f5bf8c84..3cec1c675 100644
Binary files a/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-selection-no-border-chromium-linux.png and b/browser_tests/tests/selectionToolbox.spec.ts-snapshots/selection-toolbox-single-selection-no-border-chromium-linux.png differ
diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png
index fadb02348..604c23351 100644
Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-mobile-chromium-linux.png differ
diff --git a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png
index 350b01a28..1e2a1017c 100644
Binary files a/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png and b/browser_tests/tests/templates.spec.ts-snapshots/template-grid-tablet-chromium-linux.png differ
diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/NodeHeader.spec.ts
new file mode 100644
index 000000000..7a8ae5dd2
--- /dev/null
+++ b/browser_tests/tests/vueNodes/NodeHeader.spec.ts
@@ -0,0 +1,134 @@
+import {
+ comfyExpect as expect,
+ comfyPageFixture as test
+} from '../../fixtures/ComfyPage'
+import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
+
+test.describe('NodeHeader', () => {
+ test.beforeEach(async ({ comfyPage }) => {
+ await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled')
+ await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
+ await comfyPage.setSetting('Comfy.EnableTooltips', true)
+ await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
+ await comfyPage.setup()
+ })
+
+ test('displays node title', async ({ comfyPage }) => {
+ // Get the KSampler node from the default workflow
+ const nodes = await comfyPage.getNodeRefsByType('KSampler')
+ expect(nodes.length).toBeGreaterThanOrEqual(1)
+
+ const node = nodes[0]
+ const vueNode = new VueNodeFixture(node, comfyPage.page)
+
+ const title = await vueNode.getTitle()
+ expect(title).toBe('KSampler')
+
+ // Verify title is visible in the header
+ const header = await vueNode.getHeader()
+ await expect(header).toContainText('KSampler')
+ })
+
+ test('allows title renaming', async ({ comfyPage }) => {
+ const nodes = await comfyPage.getNodeRefsByType('KSampler')
+ const node = nodes[0]
+ const vueNode = new VueNodeFixture(node, comfyPage.page)
+
+ // Test renaming with Enter
+ await vueNode.setTitle('My Custom Sampler')
+ const newTitle = await vueNode.getTitle()
+ expect(newTitle).toBe('My Custom Sampler')
+
+ // Verify the title is displayed
+ const header = await vueNode.getHeader()
+ await expect(header).toContainText('My Custom Sampler')
+
+ // Test cancel with Escape
+ const titleElement = await vueNode.getTitleElement()
+ await titleElement.dblclick()
+ await comfyPage.nextFrame()
+
+ // Type a different value but cancel
+ const input = (await vueNode.getHeader()).locator(
+ '[data-testid="node-title-input"]'
+ )
+ await input.fill('This Should Be Cancelled')
+ await input.press('Escape')
+ await comfyPage.nextFrame()
+
+ // Title should remain as the previously saved value
+ const titleAfterCancel = await vueNode.getTitle()
+ expect(titleAfterCancel).toBe('My Custom Sampler')
+ })
+
+ test('handles node collapsing', async ({ comfyPage }) => {
+ // Get the KSampler node from the default workflow
+ const nodes = await comfyPage.getNodeRefsByType('KSampler')
+ const node = nodes[0]
+ const vueNode = new VueNodeFixture(node, comfyPage.page)
+
+ // Initially should not be collapsed
+ expect(await node.isCollapsed()).toBe(false)
+ const body = await vueNode.getBody()
+ await expect(body).toBeVisible()
+
+ // Collapse the node
+ await vueNode.toggleCollapse()
+ expect(await node.isCollapsed()).toBe(true)
+
+ // Verify node content is hidden
+ const collapsedSize = await node.getSize()
+ await expect(body).not.toBeVisible()
+
+ // Expand again
+ await vueNode.toggleCollapse()
+ expect(await node.isCollapsed()).toBe(false)
+ await expect(body).toBeVisible()
+
+ // Size should be restored
+ const expandedSize = await node.getSize()
+ expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
+ })
+
+ test('shows collapse/expand icon state', async ({ comfyPage }) => {
+ const nodes = await comfyPage.getNodeRefsByType('KSampler')
+ const node = nodes[0]
+ const vueNode = new VueNodeFixture(node, comfyPage.page)
+
+ // Check initial expanded state icon
+ let iconClass = await vueNode.getCollapseIconClass()
+ expect(iconClass).toContain('pi-chevron-down')
+
+ // Collapse and check icon
+ await vueNode.toggleCollapse()
+ iconClass = await vueNode.getCollapseIconClass()
+ expect(iconClass).toContain('pi-chevron-right')
+
+ // Expand and check icon
+ await vueNode.toggleCollapse()
+ iconClass = await vueNode.getCollapseIconClass()
+ expect(iconClass).toContain('pi-chevron-down')
+ })
+
+ test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
+ const nodes = await comfyPage.getNodeRefsByType('KSampler')
+ const node = nodes[0]
+ const vueNode = new VueNodeFixture(node, comfyPage.page)
+
+ // Set custom title
+ await vueNode.setTitle('Test Sampler')
+ expect(await vueNode.getTitle()).toBe('Test Sampler')
+
+ // Collapse
+ await vueNode.toggleCollapse()
+ expect(await vueNode.getTitle()).toBe('Test Sampler')
+
+ // Expand
+ await vueNode.toggleCollapse()
+ expect(await vueNode.getTitle()).toBe('Test Sampler')
+
+ // Verify title is still displayed
+ const header = await vueNode.getHeader()
+ await expect(header).toContainText('Test Sampler')
+ })
+})
diff --git a/docs/adr/0004-crdt-based-layout-system.md b/docs/adr/0004-crdt-based-layout-system.md
new file mode 100644
index 000000000..ceb483a6f
--- /dev/null
+++ b/docs/adr/0004-crdt-based-layout-system.md
@@ -0,0 +1,156 @@
+# 4. Centralized Layout Management with CRDT
+
+Date: 2025-08-27
+
+## Status
+
+Proposed
+
+## Context
+
+ComfyUI's node graph editor currently suffers from fundamental architectural limitations around spatial data management that prevent us from achieving key product goals.
+
+### Current Architecture Problems
+
+The existing system allows each node to directly mutate its position within LiteGraph's canvas renderer. This creates several critical issues:
+
+1. **Performance Bottlenecks**: UI updates require full graph traversals to detect position changes. Large workflows (100+ nodes) can create bottlenecks during interactions due to this O(n) polling approach.
+
+2. **Position Conflicts**: Multiple systems (LiteGraph canvas, DOMwidgets.ts overlays) currently compete to control node positions. Future Vue widget overlays will compound this maintenance burden.
+
+3. **No Collaboration Foundation**: Direct position mutations make concurrent editing impossible—there's no mechanism to merge conflicting position updates from multiple users.
+
+4. **Renderer Lock-in**: Spatial data is tightly coupled to LiteGraph's canvas implementation, preventing alternative rendering approaches (WebGL, DOM, other libraries, hybrid approaches).
+
+5. **Inefficient Change Detection**: While LiteGraph provides some events, many operations require polling via changeTracker.ts. The current undo/redo system performs expensive diffs on every interaction rather than using reactive push/pull signals, creating performance bottlenecks and blocking efficient animations and viewport culling.
+
+ This represents a fundamental architectural limitation: diff-based systems scale O(n) with graph complexity (traverse entire structure to detect changes), while signal-based reactive systems scale O(1) with actual changes (data mutations automatically notify subscribers). Modern frameworks (Vue 3, Angular signals, SolidJS) have moved to reactive approaches for precisely this performance reason.
+
+### Business Context
+
+- Performance issues emerge with workflow complexity (100+ nodes)
+- The AI workflow community increasingly expects collaborative features (similar to Figma, Miro)
+- Accessibility requirements will necessitate DOM-based rendering options
+- Technical debt compounds with each new spatial feature
+
+This decision builds on [ADR-0001 (Merge LiteGraph)](0001-merge-litegraph-into-frontend.md), which enables the architectural restructuring proposed here.
+
+## Decision
+
+We will implement a centralized layout management system using CRDT (Conflict-free Replicated Data Types) with command pattern architecture to separate spatial data from rendering behavior.
+
+### Centralized State Management Foundation
+
+This solution applies proven centralized state management patterns:
+
+- **Centralized Store**: All spatial data (position, size, bounds, transform) managed in a single CRDT-backed store
+- **Command Interface**: All mutations flow through explicit commands rather than direct property access
+- **Observer Pattern**: Independent systems (rendering, interaction, layout) subscribe to state changes
+- **Domain Separation**: Layout logic completely separated from rendering and UI concerns
+
+This provides single source of truth, predictable state updates, and natural system decoupling—solving our core architectural problems.
+
+### Core Architecture
+
+1. **Centralized Layout Store**: A Yjs CRDT maintains all spatial data in a single authoritative store:
+ ```typescript
+ // Instead of: node.position = {x, y}
+ layoutStore.moveNode(nodeId, {x, y})
+ ```
+
+2. **Command Pattern**: All spatial mutations flow through explicit commands:
+ ```
+ User Input → Commands → Layout Store → Observer Notifications → Renderers
+ ```
+
+3. **Observer-Based Systems**: Multiple independent systems subscribe to layout changes:
+ - **Rendering Systems**: LiteGraph canvas, WebGL, DOM accessibility renderers
+ - **Interaction Systems**: Drag handlers, selection, hover states
+ - **Layout Systems**: Auto-layout, alignment, distribution
+ - **Animation Systems**: Smooth transitions, physics simulations
+
+4. **Reactive Updates**: Store changes propagate through observers, eliminating polling and enabling efficient system coordination.
+
+### Implementation Strategy
+
+**Phase 1: Parallel System**
+- Build CRDT layout store alongside existing system
+- Layout store initially mirrors LiteGraph changes via observers
+- Gradually migrate user interactions to use command interface
+- Maintain full backward compatibility
+
+**Phase 2: Inversion of Control**
+- CRDT store becomes single source of truth
+- LiteGraph receives position updates via reactive subscriptions
+- Enable alternative renderers and advanced features
+
+### Why Centralized State + CRDT?
+
+This combination provides both architectural and technical benefits:
+
+**Centralized State Benefits:**
+- **Single Source of Truth**: All layout data managed in one place, eliminating conflicts
+- **System Decoupling**: Rendering, interaction, and layout systems operate independently
+- **Predictable Updates**: Clear data flow makes debugging and testing easier
+- **Extensibility**: Easy to add new layout behaviors without modifying existing systems
+
+**CRDT Benefits:**
+- **Conflict Resolution**: Automatic merging eliminates position conflicts between systems
+- **Collaboration-Ready**: Built-in support for multi-user editing
+- **Eventual Consistency**: Guaranteed convergence to same state across all clients
+
+**Yjs-Specific Benefits:**
+- **Event-Driven**: Native observer pattern removes need for polling
+- **Selective Updates**: Only changed nodes trigger system updates
+- **Fine-Grained Changes**: Efficient delta synchronization
+
+## Consequences
+
+### Positive
+
+- **Eliminates Polling**: Observer pattern removes O(n) graph traversals, improving performance
+- **System Modularity**: Independent systems can be developed, tested, and optimized separately
+- **Renderer Flexibility**: Easy to add WebGL, DOM accessibility, or hybrid rendering systems
+- **Rich Interactions**: Command pattern enables robust undo/redo, macros, and interaction history
+- **Collaboration-Ready**: CRDT foundation enables real-time multi-user editing
+- **Conflict Resolution**: Eliminates position "snap-back" behavior between competing systems
+- **Better Developer Experience**: Clear separation of concerns and predictable data flow patterns
+
+### Negative
+
+- **Learning Curve**: Team must understand CRDT concepts and centralized state management
+- **Migration Complexity**: Gradual migration of existing direct property access requires careful coordination
+- **Memory Overhead**: Yjs library (~30KB) plus operation history storage
+- **CRDT Performance**: CRDTs have computational overhead compared to direct property access
+- **Increased Abstraction**: Additional layer between user interactions and visual updates
+
+### Risk Mitigations
+
+- Provide comprehensive migration documentation and examples
+- Build compatibility layer for gradual, low-risk migration
+- Implement operation history pruning for long-running sessions
+- Phase implementation to validate approach before full migration
+
+## Notes
+
+This centralized state + CRDT architecture follows patterns from modern collaborative applications:
+
+**Centralized State Management**: Similar to Redux/Vuex patterns in complex web applications, but with CRDT backing for collaboration. This provides predictable state updates while enabling real-time multi-user features.
+
+**CRDT in Collaboration**: Tools like Figma, Linear, and Notion use similar approaches for real-time collaboration, demonstrating the effectiveness of separating authoritative data from presentation logic.
+
+**Future Capabilities**: This foundation enables advanced features that would be difficult with the current architecture:
+- Macro recording and workflow automation
+- Programmatic layout optimization and constraints
+- API-driven workflow construction
+- Multiple simultaneous renderers (canvas + accessibility DOM)
+- Real-time collaborative editing
+- Advanced spatial features (physics, animations, auto-layout)
+
+The architecture provides immediate single-user benefits while creating infrastructure for collaborative and advanced spatial features.
+
+## References
+
+- [Yjs Documentation](https://docs.yjs.dev/)
+- [CRDTs: The Hard Parts](https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html) by Martin Kleppmann
+- [Figma's Multiplayer Technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/)
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 3ebf340d5..1b267345e 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -11,7 +11,7 @@ An Architecture Decision Record captures an important architectural decision mad
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
-| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
+| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
## Creating a New ADR
@@ -77,4 +77,4 @@ Optional section for additional information, references, or clarifications.
## Further Reading
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) by Michael Nygard
-- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources
\ No newline at end of file
+- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources
diff --git a/knip.config.ts b/knip.config.ts
index 800831eb9..ca98c8f2a 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -2,24 +2,32 @@ import type { KnipConfig } from 'knip'
const config: KnipConfig = {
entry: [
+ 'build/**/*.ts',
+ 'scripts/**/*.{js,ts}',
'src/main.ts',
- 'vite.config.mts',
'vite.electron.config.mts',
- 'vite.types.config.mts',
- 'eslint.config.js',
- 'tailwind.config.ts',
- 'postcss.config.js',
- 'playwright.config.ts',
- 'playwright.i18n.config.ts',
- 'vitest.config.ts',
- 'vitest.litegraph.config.ts',
- 'scripts/**/*.{js,ts}'
+ 'vite.types.config.mts'
],
project: [
+ 'browser_tests/**/*.{js,ts}',
+ 'build/**/*.{js,ts,vue}',
+ 'scripts/**/*.{js,ts}',
'src/**/*.{js,ts,vue}',
'tests-ui/**/*.{js,ts,vue}',
- 'browser_tests/**/*.{js,ts}',
- 'scripts/**/*.{js,ts}'
+ '*.{js,ts,mts}'
+ ],
+ ignoreBinaries: ['only-allow', 'openapi-typescript'],
+ ignoreDependencies: [
+ '@primeuix/forms',
+ '@primeuix/styled',
+ '@primeuix/utils',
+ '@primevue/icons',
+ '@iconify/json',
+ 'tailwindcss',
+ 'tailwindcss-primeui', // Need to figure out why tailwind plugin isn't applying
+ // Dev
+ '@executeautomation/playwright-mcp-server',
+ '@trivago/prettier-plugin-sort-imports'
],
ignore: [
// Generated files
@@ -52,34 +60,27 @@ const config: KnipConfig = {
'src/components/button/TextButton.vue',
'src/components/card/CardTitle.vue',
'src/components/card/CardDescription.vue',
- 'src/components/input/SingleSelect.vue'
+ 'src/components/input/SingleSelect.vue',
+ // Used by a custom node (that should move off of this)
+ 'src/scripts/ui/components/splitButton.ts',
+ // Generated file: openapi
+ 'src/types/comfyRegistryTypes.ts'
],
ignoreExportsUsedInFile: true,
// Vue-specific configuration
vue: true,
+ tailwind: true,
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
- binaries: 'off',
- classMembers: 'off',
- dependencies: 'off',
- devDependencies: 'off',
- duplicates: 'off',
- enumMembers: 'off',
- exports: 'off',
- nsExports: 'off',
- nsTypes: 'off',
- types: 'off',
- unlisted: 'off'
+ classMembers: 'off'
},
+ tags: [
+ '-knipIgnoreUnusedButUsedByCustomNodes',
+ '-knipIgnoreUnusedButUsedByVueNodesBranch'
+ ],
// Include dependencies analysis
- includeEntryExports: true,
- // Workspace configuration for monorepo-like structure
- workspaces: {
- '.': {
- entry: ['src/main.ts', 'playwright.i18n.config.ts']
- }
- }
+ includeEntryExports: true
}
export default config
diff --git a/package.json b/package.json
index 49a04f576..3fef60326 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
- "version": "1.26.9",
+ "version": "1.27.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -40,7 +40,6 @@
"devDependencies": {
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.6",
- "@iconify/json": "^2.2.245",
"@iconify/tailwind": "^1.2.0",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
"@lobehub/i18n-cli": "^1.25.1",
@@ -48,7 +47,6 @@
"@nx/playwright": "21.4.1",
"@nx/storybook": "21.4.1",
"@nx/vite": "21.4.1",
- "@nx/web": "21.4.1",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.52.0",
"@storybook/addon-docs": "^9.1.1",
@@ -56,17 +54,15 @@
"@storybook/vue3-vite": "^9.1.1",
"@tailwindcss/vite": "^4.1.12",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
- "@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/jsdom": "^21.1.7",
"@types/node": "^20.14.8",
"@types/semver": "^7.7.0",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
+ "@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.0.0",
"@vue/test-utils": "^2.4.6",
- "chalk": "^5.3.0",
- "commander": "^14.0.0",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",
@@ -77,18 +73,13 @@
"globals": "^15.9.0",
"happy-dom": "^15.11.0",
"husky": "^9.0.11",
- "identity-obj-proxy": "^3.0.0",
- "ink": "^6.2.2",
"jiti": "2.4.2",
"jsdom": "^26.1.0",
"knip": "^5.62.0",
"lint-staged": "^15.2.7",
"lucide-vue-next": "^0.540.0",
"nx": "21.4.1",
- "postcss": "^8.4.39",
"prettier": "^3.3.2",
- "react": "^19.1.1",
- "react-reconciler": "^0.32.0",
"storybook": "^9.1.1",
"tailwindcss": "^4.1.12",
"tailwindcss-primeui": "^0.6.1",
@@ -111,6 +102,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.69",
+ "@iconify/json": "^2.2.380",
"@primeuix/forms": "0.0.2",
"@primeuix/styled": "0.3.2",
"@primeuix/utils": "^0.3.2",
@@ -118,7 +110,6 @@
"@primevue/forms": "^4.2.5",
"@primevue/icons": "4.2.5",
"@primevue/themes": "^4.2.5",
- "@sentry/core": "^10.5.0",
"@sentry/vue": "^8.48.0",
"@tiptap/core": "^2.10.4",
"@tiptap/extension-link": "^2.10.4",
@@ -133,6 +124,8 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
+ "chart.js": "^4.5.0",
+ "clsx": "^2.1.1",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
@@ -149,12 +142,14 @@
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
+ "tailwind-merge": "^3.3.1",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"vuefire": "^3.2.1",
+ "yjs": "^13.6.27",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b1a9e5fd2..c0f1f7af8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@comfyorg/comfyui-electron-types':
specifier: ^0.4.69
version: 0.4.69
+ '@iconify/json':
+ specifier: ^2.2.380
+ version: 2.2.380
'@primeuix/forms':
specifier: 0.0.2
version: 0.0.2
@@ -38,9 +41,6 @@ importers:
'@primevue/themes':
specifier: ^4.2.5
version: 4.2.5
- '@sentry/core':
- specifier: ^10.5.0
- version: 10.5.0
'@sentry/vue':
specifier: ^8.48.0
version: 8.48.0(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))
@@ -83,6 +83,12 @@ importers:
axios:
specifier: ^1.8.2
version: 1.11.0
+ chart.js:
+ specifier: ^4.5.0
+ version: 4.5.0
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -131,6 +137,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.2
+ tailwind-merge:
+ specifier: ^3.3.1
+ version: 3.3.1
three:
specifier: ^0.170.0
version: 0.170.0
@@ -149,6 +158,9 @@ importers:
vuefire:
specifier: ^3.2.1
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.2))
+ yjs:
+ specifier: ^13.6.27
+ version: 13.6.27
zod:
specifier: ^3.23.8
version: 3.24.1
@@ -162,9 +174,6 @@ importers:
'@executeautomation/playwright-mcp-server':
specifier: ^1.0.6
version: 1.0.6(react@19.1.1)(ws@8.18.3)(zod@3.24.1)
- '@iconify/json':
- specifier: ^2.2.245
- version: 2.2.245
'@iconify/tailwind':
specifier: ^1.2.0
version: 1.2.0
@@ -186,9 +195,6 @@ importers:
'@nx/vite':
specifier: 21.4.1
version: 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)(typescript@5.9.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vitest@3.2.4)
- '@nx/web':
- specifier: 21.4.1
- version: 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
'@pinia/testing':
specifier: ^0.1.5
version: 0.1.5(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))
@@ -209,10 +215,7 @@ importers:
version: 4.1.12(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
'@trivago/prettier-plugin-sort-imports':
specifier: ^5.2.0
- version: 5.2.0(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)
- '@types/dompurify':
- specifier: ^3.0.5
- version: 3.0.5
+ version: 5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
@@ -231,18 +234,15 @@ importers:
'@vitejs/plugin-vue':
specifier: ^5.1.4
version: 5.1.4(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))(vue@3.5.13(typescript@5.9.2))
+ '@vitest/coverage-v8':
+ specifier: ^3.2.4
+ version: 3.2.4(vitest@3.2.4)
'@vitest/ui':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4)
'@vue/test-utils':
specifier: ^2.4.6
version: 2.4.6
- chalk:
- specifier: ^5.3.0
- version: 5.3.0
- commander:
- specifier: ^14.0.0
- version: 14.0.0
eslint:
specifier: ^9.12.0
version: 9.12.0(jiti@2.4.2)
@@ -273,12 +273,6 @@ importers:
husky:
specifier: ^9.0.11
version: 9.0.11
- identity-obj-proxy:
- specifier: ^3.0.0
- version: 3.0.0
- ink:
- specifier: ^6.2.2
- version: 6.2.2(@types/react@19.1.9)(react@19.1.1)
jiti:
specifier: 2.4.2
version: 2.4.2
@@ -297,18 +291,9 @@ importers:
nx:
specifier: 21.4.1
version: 21.4.1
- postcss:
- specifier: ^8.4.39
- version: 8.5.1
prettier:
specifier: ^3.3.2
version: 3.3.2
- react:
- specifier: ^19.1.1
- version: 19.1.1
- react-reconciler:
- specifier: ^0.32.0
- version: 0.32.0(react@19.1.1)
storybook:
specifier: ^9.1.1
version: 9.1.1(@testing-library/dom@10.4.1)(prettier@3.3.2)(vite@5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2))
@@ -507,10 +492,6 @@ packages:
resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==}
engines: {node: '>=6.9.0'}
- '@babel/generator@7.27.1':
- resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==}
- engines: {node: '>=6.9.0'}
-
'@babel/generator@7.28.3':
resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==}
engines: {node: '>=6.9.0'}
@@ -602,11 +583,6 @@ packages:
resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==}
engines: {node: '>=6.9.0'}
- '@babel/parser@7.27.2':
- resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==}
- engines: {node: '>=6.0.0'}
- hasBin: true
-
'@babel/parser@7.28.3':
resolution: {integrity: sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==}
engines: {node: '>=6.0.0'}
@@ -1042,22 +1018,18 @@ packages:
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
- '@babel/traverse@7.27.1':
- resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==}
- engines: {node: '>=6.9.0'}
-
'@babel/traverse@7.28.3':
resolution: {integrity: sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==}
engines: {node: '>=6.9.0'}
- '@babel/types@7.27.1':
- resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==}
- engines: {node: '>=6.9.0'}
-
'@babel/types@7.28.2':
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
engines: {node: '>=6.9.0'}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@comfyorg/comfyui-electron-types@0.4.69':
resolution: {integrity: sha512-emEapJvbbx8lXiJ/84gmk+fYU73MmqkQKgBDQkyDwctcOb+eNe347PaH/+0AIjX8A/DtFHfnwgh9J8k3RVdqZA==}
@@ -1663,8 +1635,8 @@ packages:
resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
engines: {node: '>=18.18'}
- '@iconify/json@2.2.245':
- resolution: {integrity: sha512-JbruddbGKghBe6fE1mzuo5hhUkisIW4mAdQGAyx0Q6sI52ukeQJHakolc2RQD/yWC3xp7rARNXMzWSXJynJ1vw==}
+ '@iconify/json@2.2.380':
+ resolution: {integrity: sha512-+Al/Q+mMB/nLz/tawmJEOkCs6+RKKVUS/Yg9I80h2yRpu0kIzxVLQRfF0NifXz/fH92vDVXbS399wio4lMVF4Q==}
'@iconify/tailwind@1.2.0':
resolution: {integrity: sha512-KgpIHWOTcRYw1XcoUqyNSrmYyfLLqZYu3AmP8zdfLk0F5TqRO8YerhlvlQmGfn7rJXgPeZN569xPAJnJ53zZxA==}
@@ -1709,6 +1681,10 @@ packages:
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
+ '@istanbuljs/schema@0.1.3':
+ resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==}
+ engines: {node: '>=8'}
+
'@jest/diff-sequences@30.0.1':
resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==}
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -1724,10 +1700,6 @@ packages:
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
- '@jridgewell/gen-mapping@0.3.5':
- resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==}
- engines: {node: '>=6.0.0'}
-
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
@@ -1735,10 +1707,6 @@ packages:
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
- '@jridgewell/set-array@1.2.1':
- resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
- engines: {node: '>=6.0.0'}
-
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
@@ -1748,12 +1716,12 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
- '@jridgewell/trace-mapping@0.3.25':
- resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
-
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
+ '@kurkle/color@0.3.4':
+ resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
+
'@lobehub/cli-ui@1.13.0':
resolution: {integrity: sha512-7kXm84dc6yiniEFb/KRZv5H4g43n+xKTSpKSczlv54DY3tHSuZjBARyI/UDxFVgn7ezWYAIFuphzs0hSdhs6hw==}
engines: {node: '>=18'}
@@ -1903,9 +1871,6 @@ packages:
vite: ^5.0.0 || ^6.0.0
vitest: ^1.3.1 || ^2.0.0 || ^3.0.0
- '@nx/web@21.4.1':
- resolution: {integrity: sha512-SavfXtoCfvb+JmyDp1QHqLDyNUOgph1oQF9xgsNKCXXlIccBGxlsBPQR94qPYC290Hn4QvpLg0AYK6oNHPap2Q==}
-
'@nx/workspace@21.4.1':
resolution: {integrity: sha512-3e33eTb1hRx6/i416Wc0mk/TPANxjx2Kz8ecnyqFFII5CM9tX7CPCwDF4O75N9mysI6PCKJ+Hc/1q76HZR4UgA==}
@@ -2272,10 +2237,6 @@ packages:
resolution: {integrity: sha512-fuuVULB5/1vI8NoIwXwR3xwhJJqk+y4RdSdajExGF7nnUDBpwUJyXsmYJnOkBO+oLeEs58xaCpotCKiPUNnE3g==}
engines: {node: '>=14.18'}
- '@sentry/core@10.5.0':
- resolution: {integrity: sha512-jTJ8NhZSKB2yj3QTVRXfCCngQzAOLThQUxCl9A7Mv+XF10tP7xbH/88MVQ5WiOr2IzcmrB9r2nmUe36BnMlLjA==}
- engines: {node: '>=18'}
-
'@sentry/core@8.48.0':
resolution: {integrity: sha512-VGwYgTfLpvJ5LRO5A+qWo1gpo6SfqaGXL9TOzVgBucAdpzbrYHpZ87sEarDVq/4275uk1b0S293/mfsskFczyw==}
engines: {node: '>=14.18'}
@@ -2586,14 +2547,14 @@ packages:
'@tiptap/starter-kit@2.10.4':
resolution: {integrity: sha512-tu/WCs9Mkr5Nt8c3/uC4VvAbQlVX0OY7ygcqdzHGUeG9zP3twdW7o5xM3kyDKR2++sbVzqu5Ll5qNU+1JZvPGQ==}
- '@trivago/prettier-plugin-sort-imports@5.2.0':
- resolution: {integrity: sha512-yEIJ7xMKYQwyNRjxSdi4Gs37iszikAjxfky+3hu9bn24u8eHLJNDMAoOTyowp8p6EpSl8IQMdkfBx+WnJTttsw==}
+ '@trivago/prettier-plugin-sort-imports@5.2.2':
+ resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
engines: {node: '>18.12'}
peerDependencies:
'@vue/compiler-sfc': 3.x
prettier: 2.x - 3.x
prettier-plugin-svelte: 3.x
- svelte: 4.x
+ svelte: 4.x || 5.x
peerDependenciesMeta:
'@vue/compiler-sfc':
optional: true
@@ -2629,9 +2590,6 @@ packages:
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
- '@types/dompurify@3.0.5':
- resolution: {integrity: sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==}
-
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -2820,6 +2778,15 @@ packages:
vite: ^5.0.0
vue: ^3.2.25
+ '@vitest/coverage-v8@3.2.4':
+ resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==}
+ peerDependencies:
+ '@vitest/browser': 3.2.4
+ vitest: 3.2.4
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
@@ -3148,12 +3115,12 @@ packages:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
+ ast-v8-to-istanbul@0.3.5:
+ resolution: {integrity: sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==}
+
async@3.2.5:
resolution: {integrity: sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==}
- async@3.2.6:
- resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
-
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -3220,10 +3187,6 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
- basic-auth@2.0.1:
- resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
- engines: {node: '>= 0.8'}
-
better-opn@3.0.2:
resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==}
engines: {node: '>=12.0.0'}
@@ -3342,6 +3305,10 @@ packages:
charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
+ chart.js@4.5.0:
+ resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
+ engines: {pnpm: '>=8'}
+
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -3394,6 +3361,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
+ clsx@2.1.1:
+ resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
+ engines: {node: '>=6'}
+
code-excerpt@4.0.0:
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3428,10 +3399,6 @@ packages:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
- commander@14.0.0:
- resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==}
- engines: {node: '>=20'}
-
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -3516,10 +3483,6 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
- corser@2.0.1:
- resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==}
- engines: {node: '>= 0.4.0'}
-
cosmiconfig@7.1.0:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
@@ -3987,9 +3950,6 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
- eventemitter3@4.0.7:
- resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
-
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
@@ -4272,10 +4232,6 @@ packages:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
- globals@11.12.0:
- resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
- engines: {node: '>=4'}
-
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
@@ -4316,9 +4272,6 @@ packages:
resolution: {integrity: sha512-/zyxHbXriYJ8b9Urh43ILk/jd9tC07djURnJuAimJ3tJCOLOzOUp7dEHDwJOZyzROlrrooUhr/0INZIDBj1Bjw==}
engines: {node: '>=18.0.0'}
- harmony-reflect@1.6.2:
- resolution: {integrity: sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==}
-
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -4352,14 +4305,13 @@ packages:
resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==}
engines: {node: ^16.14.0 || >=18.0.0}
- html-encoding-sniffer@3.0.0:
- resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==}
- engines: {node: '>=12'}
-
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
+ html-escaper@2.0.2:
+ resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
+
html-minifier-terser@6.1.0:
resolution: {integrity: sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==}
engines: {node: '>=12'}
@@ -4376,15 +4328,6 @@ packages:
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
engines: {node: '>= 14'}
- http-proxy@1.18.1:
- resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==}
- engines: {node: '>=8.0.0'}
-
- http-server@14.1.1:
- resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==}
- engines: {node: '>=12'}
- hasBin: true
-
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
@@ -4412,10 +4355,6 @@ packages:
idb@7.1.1:
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
- identity-obj-proxy@3.0.0:
- resolution: {integrity: sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==}
- engines: {node: '>=4'}
-
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
@@ -4624,6 +4563,25 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ isomorphic.js@0.2.5:
+ resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
+
+ istanbul-lib-coverage@3.2.2:
+ resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
+ engines: {node: '>=8'}
+
+ istanbul-lib-report@3.0.1:
+ resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
+ engines: {node: '>=10'}
+
+ istanbul-lib-source-maps@5.0.6:
+ resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==}
+ engines: {node: '>=10'}
+
+ istanbul-reports@3.2.0:
+ resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
+ engines: {node: '>=8'}
+
jackspeak@3.4.0:
resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==}
engines: {node: '>=14'}
@@ -4794,6 +4752,11 @@ packages:
resolution: {integrity: sha512-vzaalVBmFLnMaedq0QAsBAaXsWahzRpvnIBdBjj7y+7EKTS6lnziU2y/PsU2c6rV5qYj2B5IDw0uNJ9peXD0vw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
+ lib0@0.2.114:
+ resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
+ engines: {node: '>=16'}
+ hasBin: true
+
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -4975,6 +4938,13 @@ packages:
magic-string@0.30.18:
resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==}
+ magicast@0.3.5:
+ resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
+
+ make-dir@4.0.0:
+ resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
+ engines: {node: '>=10'}
+
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
@@ -5176,11 +5146,6 @@ packages:
resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==}
engines: {node: '>= 0.6'}
- mime@1.6.0:
- resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
- engines: {node: '>=4'}
- hasBin: true
-
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
@@ -5402,10 +5367,6 @@ packages:
zod:
optional: true
- opener@1.5.2:
- resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==}
- hasBin: true
-
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -5606,10 +5567,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
- portfinder@1.0.37:
- resolution: {integrity: sha512-yuGIEjDAYnnOex9ddMnKZEMFE0CcGo6zbfzDklkmT1m5z734ss6JMzN9rNB3+RR7iS+F10D4/BVIaXOyh8PQKw==}
- engines: {node: '>= 10.12'}
-
postcss-selector-parser@6.1.0:
resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==}
engines: {node: '>=4'}
@@ -5901,9 +5858,6 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
- requires-port@1.0.0:
- resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
-
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -5984,9 +5938,6 @@ packages:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
- secure-compare@3.0.1:
- resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
-
secure-json-parse@2.7.0:
resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==}
@@ -6230,6 +6181,9 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
+ tailwind-merge@3.3.1:
+ resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
+
tailwindcss-primeui@0.6.1:
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
peerDependencies:
@@ -6255,6 +6209,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ test-exclude@7.0.1:
+ resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==}
+ engines: {node: '>=18'}
+
text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
@@ -6464,10 +6422,6 @@ packages:
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
- union@0.5.0:
- resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==}
- engines: {node: '>= 0.8.0'}
-
unist-util-is@6.0.0:
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
@@ -6549,9 +6503,6 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
- url-join@4.0.1:
- resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==}
-
use-sync-external-store@1.5.0:
resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==}
peerDependencies:
@@ -6818,10 +6769,6 @@ packages:
resolution: {integrity: sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==}
engines: {node: '>=0.8.0'}
- whatwg-encoding@2.0.0:
- resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==}
- engines: {node: '>=12'}
-
whatwg-encoding@3.1.1:
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
engines: {node: '>=18'}
@@ -6963,6 +6910,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
+ yjs@13.6.27:
+ resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
+ engines: {node: '>=16.0.0', npm: '>=8.0.0'}
+
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -7152,8 +7103,8 @@ snapshots:
'@ampproject/remapping@2.3.0':
dependencies:
- '@jridgewell/gen-mapping': 0.3.5
- '@jridgewell/trace-mapping': 0.3.25
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.30
'@antfu/install-pkg@0.5.0':
dependencies:
@@ -7225,14 +7176,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/generator@7.27.1':
- dependencies:
- '@babel/parser': 7.28.3
- '@babel/types': 7.28.2
- '@jridgewell/gen-mapping': 0.3.5
- '@jridgewell/trace-mapping': 0.3.25
- jsesc: 3.1.0
-
'@babel/generator@7.28.3':
dependencies:
'@babel/parser': 7.28.3
@@ -7359,10 +7302,6 @@ snapshots:
'@babel/template': 7.27.2
'@babel/types': 7.28.2
- '@babel/parser@7.27.2':
- dependencies:
- '@babel/types': 7.28.2
-
'@babel/parser@7.28.3':
dependencies:
'@babel/types': 7.28.2
@@ -7911,18 +7850,6 @@ snapshots:
'@babel/parser': 7.28.3
'@babel/types': 7.28.2
- '@babel/traverse@7.27.1':
- dependencies:
- '@babel/code-frame': 7.27.1
- '@babel/generator': 7.28.3
- '@babel/parser': 7.28.3
- '@babel/template': 7.27.2
- '@babel/types': 7.28.2
- debug: 4.4.1
- globals: 11.12.0
- transitivePeerDependencies:
- - supports-color
-
'@babel/traverse@7.28.3':
dependencies:
'@babel/code-frame': 7.27.1
@@ -7935,16 +7862,13 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/types@7.27.1':
- dependencies:
- '@babel/helper-string-parser': 7.27.1
- '@babel/helper-validator-identifier': 7.27.1
-
'@babel/types@7.28.2':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@comfyorg/comfyui-electron-types@0.4.69': {}
'@csstools/color-helpers@5.1.0': {}
@@ -8523,7 +8447,7 @@ snapshots:
'@humanwhocodes/retry@0.3.1': {}
- '@iconify/json@2.2.245':
+ '@iconify/json@2.2.380':
dependencies:
'@iconify/types': 2.0.0
pathe: 1.1.2
@@ -8603,6 +8527,8 @@ snapshots:
dependencies:
minipass: 7.1.2
+ '@istanbuljs/schema@0.1.3': {}
+
'@jest/diff-sequences@30.0.1': {}
'@jest/get-type@30.1.0': {}
@@ -8616,12 +8542,6 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.30
- '@jridgewell/gen-mapping@0.3.5':
- dependencies:
- '@jridgewell/set-array': 1.2.1
- '@jridgewell/sourcemap-codec': 1.5.5
- '@jridgewell/trace-mapping': 0.3.25
-
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -8629,8 +8549,6 @@ snapshots:
'@jridgewell/resolve-uri@3.1.2': {}
- '@jridgewell/set-array@1.2.1': {}
-
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -8640,16 +8558,13 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
- '@jridgewell/trace-mapping@0.3.25':
- dependencies:
- '@jridgewell/resolve-uri': 3.1.2
- '@jridgewell/sourcemap-codec': 1.5.5
-
'@jridgewell/trace-mapping@0.3.30':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@kurkle/color@0.3.4': {}
+
'@lobehub/cli-ui@1.13.0(@types/react@19.1.9)':
dependencies:
arr-rotate: 1.0.0
@@ -8983,23 +8898,6 @@ snapshots:
- typescript
- verdaccio
- '@nx/web@21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)':
- dependencies:
- '@nx/devkit': 21.4.1(nx@21.4.1)
- '@nx/js': 21.4.1(@babel/traverse@7.28.3)(nx@21.4.1)
- detect-port: 1.6.1
- http-server: 14.1.1
- picocolors: 1.1.1
- tslib: 2.8.1
- transitivePeerDependencies:
- - '@babel/traverse'
- - '@swc-node/register'
- - '@swc/core'
- - debug
- - nx
- - supports-color
- - verdaccio
-
'@nx/workspace@21.4.1':
dependencies:
'@nx/devkit': 21.4.1(nx@21.4.1)
@@ -9316,8 +9214,6 @@ snapshots:
'@sentry-internal/replay-canvas': 8.48.0
'@sentry/core': 8.48.0
- '@sentry/core@10.5.0': {}
-
'@sentry/core@8.48.0': {}
'@sentry/vue@8.48.0(pinia@2.2.2(typescript@5.9.2)(vue@3.5.13(typescript@5.9.2)))(vue@3.5.13(typescript@5.9.2))':
@@ -9641,12 +9537,12 @@ snapshots:
'@tiptap/extension-text-style': 2.10.4(@tiptap/core@2.10.4(@tiptap/pm@2.10.4))
'@tiptap/pm': 2.10.4
- '@trivago/prettier-plugin-sort-imports@5.2.0(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)':
+ '@trivago/prettier-plugin-sort-imports@5.2.2(@vue/compiler-sfc@3.5.13)(prettier@3.3.2)':
dependencies:
- '@babel/generator': 7.27.1
- '@babel/parser': 7.27.2
- '@babel/traverse': 7.27.1
- '@babel/types': 7.27.1
+ '@babel/generator': 7.28.3
+ '@babel/parser': 7.28.3
+ '@babel/traverse': 7.28.3
+ '@babel/types': 7.28.2
javascript-natural-sort: 0.7.1
lodash: 4.17.21
prettier: 3.3.2
@@ -9682,10 +9578,6 @@ snapshots:
'@types/diff-match-patch@1.0.36': {}
- '@types/dompurify@3.0.5':
- dependencies:
- '@types/trusted-types': 2.0.7
-
'@types/estree@1.0.5': {}
'@types/estree@1.0.6': {}
@@ -9769,7 +9661,8 @@ snapshots:
'@types/tough-cookie@4.0.5': {}
- '@types/trusted-types@2.0.7': {}
+ '@types/trusted-types@2.0.7':
+ optional: true
'@types/unist@3.0.3': {}
@@ -9915,6 +9808,25 @@ snapshots:
vite: 5.4.19(@types/node@20.14.10)(lightningcss@1.30.1)(terser@5.39.2)
vue: 3.5.13(typescript@5.9.2)
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ '@bcoe/v8-coverage': 1.0.2
+ ast-v8-to-istanbul: 0.3.5
+ debug: 4.4.1
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 5.0.6
+ istanbul-reports: 3.2.0
+ magic-string: 0.30.18
+ magicast: 0.3.5
+ std-env: 3.9.0
+ test-exclude: 7.0.1
+ tinyrainbow: 2.0.0
+ vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/expect@3.2.4':
dependencies:
'@types/chai': 5.2.2
@@ -10336,9 +10248,13 @@ snapshots:
dependencies:
tslib: 2.8.1
- async@3.2.5: {}
+ ast-v8-to-istanbul@0.3.5:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.30
+ estree-walker: 3.0.3
+ js-tokens: 9.0.1
- async@3.2.6: {}
+ async@3.2.5: {}
asynckit@0.4.0: {}
@@ -10420,10 +10336,6 @@ snapshots:
base64-js@1.5.1: {}
- basic-auth@2.0.1:
- dependencies:
- safe-buffer: 5.1.2
-
better-opn@3.0.2:
dependencies:
open: 8.4.2
@@ -10564,6 +10476,10 @@ snapshots:
charenc@0.0.2: {}
+ chart.js@4.5.0:
+ dependencies:
+ '@kurkle/color': 0.3.4
+
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -10613,6 +10529,8 @@ snapshots:
clone@1.0.4: {}
+ clsx@2.1.1: {}
+
code-excerpt@4.0.0:
dependencies:
convert-to-spaces: 2.0.1
@@ -10640,8 +10558,6 @@ snapshots:
commander@13.1.0: {}
- commander@14.0.0: {}
-
commander@2.20.3: {}
commander@8.3.0: {}
@@ -10720,8 +10636,6 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
- corser@2.0.1: {}
-
cosmiconfig@7.1.0:
dependencies:
'@types/parse-json': 4.0.2
@@ -11206,8 +11120,6 @@ snapshots:
event-target-shim@5.0.1: {}
- eventemitter3@4.0.7: {}
-
eventemitter3@5.0.1: {}
eventsource-parser@3.0.5: {}
@@ -11582,8 +11494,6 @@ snapshots:
dependencies:
ini: 4.1.1
- globals@11.12.0: {}
-
globals@13.24.0:
dependencies:
type-fest: 0.20.2
@@ -11624,8 +11534,6 @@ snapshots:
webidl-conversions: 7.0.0
whatwg-mimetype: 3.0.0
- harmony-reflect@1.6.2: {}
-
has-flag@4.0.0: {}
has-property-descriptors@1.0.2:
@@ -11652,14 +11560,12 @@ snapshots:
dependencies:
lru-cache: 10.3.0
- html-encoding-sniffer@3.0.0:
- dependencies:
- whatwg-encoding: 2.0.0
-
html-encoding-sniffer@4.0.0:
dependencies:
whatwg-encoding: 3.1.1
+ html-escaper@2.0.2: {}
+
html-minifier-terser@6.1.0:
dependencies:
camel-case: 4.1.2
@@ -11687,33 +11593,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- http-proxy@1.18.1:
- dependencies:
- eventemitter3: 4.0.7
- follow-redirects: 1.15.6
- requires-port: 1.0.0
- transitivePeerDependencies:
- - debug
-
- http-server@14.1.1:
- dependencies:
- basic-auth: 2.0.1
- chalk: 4.1.2
- corser: 2.0.1
- he: 1.2.0
- html-encoding-sniffer: 3.0.0
- http-proxy: 1.18.1
- mime: 1.6.0
- minimist: 1.2.8
- opener: 1.5.2
- portfinder: 1.0.37
- secure-compare: 3.0.1
- union: 0.5.0
- url-join: 4.0.1
- transitivePeerDependencies:
- - debug
- - supports-color
-
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
@@ -11737,10 +11616,6 @@ snapshots:
idb@7.1.1: {}
- identity-obj-proxy@3.0.0:
- dependencies:
- harmony-reflect: 1.6.2
-
ieee754@1.2.1: {}
ignore@5.3.1: {}
@@ -11909,6 +11784,29 @@ snapshots:
isexe@2.0.0: {}
+ isomorphic.js@0.2.5: {}
+
+ istanbul-lib-coverage@3.2.2: {}
+
+ istanbul-lib-report@3.0.1:
+ dependencies:
+ istanbul-lib-coverage: 3.2.2
+ make-dir: 4.0.0
+ supports-color: 7.2.0
+
+ istanbul-lib-source-maps@5.0.6:
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.30
+ debug: 4.4.1
+ istanbul-lib-coverage: 3.2.2
+ transitivePeerDependencies:
+ - supports-color
+
+ istanbul-reports@3.2.0:
+ dependencies:
+ html-escaper: 2.0.2
+ istanbul-lib-report: 3.0.1
+
jackspeak@3.4.0:
dependencies:
'@isaacs/cliui': 8.0.2
@@ -12101,6 +11999,10 @@ snapshots:
lex@1.7.9: {}
+ lib0@0.2.114:
+ dependencies:
+ isomorphic.js: 0.2.5
+
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -12266,6 +12168,16 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+ magicast@0.3.5:
+ dependencies:
+ '@babel/parser': 7.28.3
+ '@babel/types': 7.28.2
+ source-map-js: 1.2.1
+
+ make-dir@4.0.0:
+ dependencies:
+ semver: 7.7.2
+
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0:
@@ -12669,8 +12581,6 @@ snapshots:
dependencies:
mime-db: 1.54.0
- mime@1.6.0: {}
-
mimic-fn@2.1.0: {}
mimic-fn@4.0.0: {}
@@ -12902,8 +12812,6 @@ snapshots:
transitivePeerDependencies:
- encoding
- opener@1.5.2: {}
-
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -13104,13 +13012,6 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
- portfinder@1.0.37:
- dependencies:
- async: 3.2.6
- debug: 4.4.1
- transitivePeerDependencies:
- - supports-color
-
postcss-selector-parser@6.1.0:
dependencies:
cssesc: 3.0.0
@@ -13528,8 +13429,6 @@ snapshots:
require-from-string@2.0.2: {}
- requires-port@1.0.0: {}
-
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -13623,8 +13522,6 @@ snapshots:
extend-shallow: 2.0.1
kind-of: 6.0.3
- secure-compare@3.0.1: {}
-
secure-json-parse@2.7.0: {}
semver@6.3.1: {}
@@ -13889,6 +13786,8 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
+ tailwind-merge@3.3.1: {}
+
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
dependencies:
tailwindcss: 4.1.12
@@ -13921,6 +13820,12 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
+ test-exclude@7.0.1:
+ dependencies:
+ '@istanbuljs/schema': 0.1.3
+ glob: 10.4.5
+ minimatch: 9.0.5
+
text-table@0.2.0: {}
three@0.170.0: {}
@@ -14087,10 +13992,6 @@ snapshots:
trough: 2.2.0
vfile: 6.0.3
- union@0.5.0:
- dependencies:
- qs: 6.14.0
-
unist-util-is@6.0.0:
dependencies:
'@types/unist': 3.0.3
@@ -14183,8 +14084,6 @@ snapshots:
dependencies:
punycode: 2.3.1
- url-join@4.0.1: {}
-
use-sync-external-store@1.5.0(react@19.1.1):
dependencies:
react: 19.1.1
@@ -14489,10 +14388,6 @@ snapshots:
websocket-extensions@0.1.4: {}
- whatwg-encoding@2.0.0:
- dependencies:
- iconv-lite: 0.6.3
-
whatwg-encoding@3.1.1:
dependencies:
iconv-lite: 0.6.3
@@ -14604,6 +14499,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
+ yjs@13.6.27:
+ dependencies:
+ lib0: 0.2.114
+
yocto-queue@0.1.0: {}
yoctocolors@2.1.1: {}
diff --git a/src/assets/css/style.css b/src/assets/css/style.css
index dcf8e55fe..ee6e697f0 100644
--- a/src/assets/css/style.css
+++ b/src/assets/css/style.css
@@ -7,6 +7,66 @@
@config '../../../tailwind.config.ts';
+@layer tailwind-utilities {
+ /* Set default values to prevent some styles from not working properly. */
+ *, ::before, ::after {
+ --tw-border-spacing-x: 0;
+ --tw-border-spacing-y: 0;
+ --tw-translate-x: 0;
+ --tw-translate-y: 0;
+ --tw-rotate: 0;
+ --tw-skew-x: 0;
+ --tw-skew-y: 0;
+ --tw-scale-x: 1;
+ --tw-scale-y: 1;
+ --tw-pan-x: ;
+ --tw-pan-y: ;
+ --tw-pinch-zoom: ;
+ --tw-scroll-snap-strictness: proximity;
+ --tw-gradient-from-position: ;
+ --tw-gradient-via-position: ;
+ --tw-gradient-to-position: ;
+ --tw-ordinal: ;
+ --tw-slashed-zero: ;
+ --tw-numeric-figure: ;
+ --tw-numeric-spacing: ;
+ --tw-numeric-fraction: ;
+ --tw-ring-inset: ;
+ --tw-ring-offset-width: 0px;
+ --tw-ring-offset-color: #fff;
+ --tw-ring-color: rgb(66 153 225 / 0.5);
+ --tw-ring-offset-shadow: 0 0 #0000;
+ --tw-ring-shadow: 0 0 #0000;
+ --tw-shadow: 0 0 #0000;
+ --tw-shadow-colored: 0 0 #0000;
+ --tw-blur: ;
+ --tw-brightness: ;
+ --tw-contrast: ;
+ --tw-grayscale: ;
+ --tw-hue-rotate: ;
+ --tw-invert: ;
+ --tw-saturate: ;
+ --tw-sepia: ;
+ --tw-drop-shadow: ;
+ --tw-backdrop-blur: ;
+ --tw-backdrop-brightness: ;
+ --tw-backdrop-contrast: ;
+ --tw-backdrop-grayscale: ;
+ --tw-backdrop-hue-rotate: ;
+ --tw-backdrop-invert: ;
+ --tw-backdrop-opacity: ;
+ --tw-backdrop-saturate: ;
+ --tw-backdrop-sepia: ;
+ --tw-contain-size: ;
+ --tw-contain-layout: ;
+ --tw-contain-paint: ;
+ --tw-contain-style: ;
+ }
+
+ @tailwind components;
+ @tailwind utilities;
+}
+
:root {
--fg-color: #000;
--bg-color: #fff;
@@ -29,7 +89,7 @@
--content-fg: #000;
--content-hover-bg: #adadad;
--content-hover-fg: #000;
-
+
/* Code styling colors for help menu*/
--code-text-color: rgba(0, 122, 255, 1);
--code-bg-color: rgba(96, 165, 250, 0.2);
@@ -136,6 +196,188 @@ body {
border: thin solid;
}
+/* Shared markdown content styling for consistent rendering across components */
+.comfy-markdown-content {
+ /* Typography */
+ font-size: 0.875rem; /* text-sm */
+ line-height: 1.6;
+ word-wrap: break-word;
+}
+
+/* Headings */
+.comfy-markdown-content h1 {
+ font-size: 22px; /* text-[22px] */
+ font-weight: 700; /* font-bold */
+ margin-top: 2rem; /* mt-8 */
+ margin-bottom: 1rem; /* mb-4 */
+}
+
+.comfy-markdown-content h1:first-child {
+ margin-top: 0; /* first:mt-0 */
+}
+
+.comfy-markdown-content h2 {
+ font-size: 18px; /* text-[18px] */
+ font-weight: 700; /* font-bold */
+ margin-top: 2rem; /* mt-8 */
+ margin-bottom: 1rem; /* mb-4 */
+}
+
+.comfy-markdown-content h2:first-child {
+ margin-top: 0; /* first:mt-0 */
+}
+
+.comfy-markdown-content h3 {
+ font-size: 16px; /* text-[16px] */
+ font-weight: 700; /* font-bold */
+ margin-top: 2rem; /* mt-8 */
+ margin-bottom: 1rem; /* mb-4 */
+}
+
+.comfy-markdown-content h3:first-child {
+ margin-top: 0; /* first:mt-0 */
+}
+
+.comfy-markdown-content h4,
+.comfy-markdown-content h5,
+.comfy-markdown-content h6 {
+ margin-top: 2rem; /* mt-8 */
+ margin-bottom: 1rem; /* mb-4 */
+}
+
+.comfy-markdown-content h4:first-child,
+.comfy-markdown-content h5:first-child,
+.comfy-markdown-content h6:first-child {
+ margin-top: 0; /* first:mt-0 */
+}
+
+/* Paragraphs */
+.comfy-markdown-content p {
+ margin: 0 0 0.5em;
+}
+
+.comfy-markdown-content p:last-child {
+ margin-bottom: 0;
+}
+
+/* First child reset */
+.comfy-markdown-content *:first-child {
+ margin-top: 0; /* mt-0 */
+}
+
+/* Lists */
+.comfy-markdown-content ul,
+.comfy-markdown-content ol {
+ padding-left: 2rem; /* pl-8 */
+ margin: 0.5rem 0; /* my-2 */
+}
+
+/* Nested lists */
+.comfy-markdown-content ul ul,
+.comfy-markdown-content ol ol,
+.comfy-markdown-content ul ol,
+.comfy-markdown-content ol ul {
+ padding-left: 1.5rem; /* pl-6 */
+ margin: 0.5rem 0; /* my-2 */
+}
+
+.comfy-markdown-content li {
+ margin: 0.5rem 0; /* my-2 */
+}
+
+/* Code */
+.comfy-markdown-content code {
+ color: var(--code-text-color);
+ background-color: var(--code-bg-color);
+ border-radius: 0.25rem; /* rounded */
+ padding: 0.125rem 0.375rem; /* px-1.5 py-0.5 */
+ font-family: monospace;
+}
+
+.comfy-markdown-content pre {
+ background-color: var(--code-block-bg-color);
+ border-radius: 0.25rem; /* rounded */
+ padding: 1rem; /* p-4 */
+ margin: 1rem 0; /* my-4 */
+ overflow-x: auto; /* overflow-x-auto */
+}
+
+.comfy-markdown-content pre code {
+ background-color: transparent; /* bg-transparent */
+ padding: 0; /* p-0 */
+ color: var(--p-text-color);
+}
+
+/* Tables */
+.comfy-markdown-content table {
+ width: 100%; /* w-full */
+ border-collapse: collapse; /* border-collapse */
+}
+
+.comfy-markdown-content th,
+.comfy-markdown-content td {
+ padding: 0.5rem; /* px-2 py-2 */
+}
+
+.comfy-markdown-content th {
+ color: var(--fg-color);
+}
+
+.comfy-markdown-content td {
+ color: var(--drag-text);
+}
+
+.comfy-markdown-content tr {
+ border-bottom: 1px solid var(--content-bg);
+}
+
+.comfy-markdown-content tr:last-child {
+ border-bottom: none;
+}
+
+.comfy-markdown-content thead {
+ border-bottom: 1px solid var(--p-text-color);
+}
+
+/* Links */
+.comfy-markdown-content a {
+ color: var(--drag-text);
+ text-decoration: underline;
+}
+
+/* Media */
+.comfy-markdown-content img,
+.comfy-markdown-content video {
+ max-width: 100%; /* max-w-full */
+ height: auto; /* h-auto */
+ display: block; /* block */
+ margin-bottom: 1rem; /* mb-4 */
+}
+
+/* Blockquotes */
+.comfy-markdown-content blockquote {
+ border-left: 3px solid var(--p-primary-color, var(--primary-bg));
+ padding-left: 0.75em;
+ margin: 0.5em 0;
+ opacity: 0.8;
+}
+
+/* Horizontal rule */
+.comfy-markdown-content hr {
+ border: none;
+ border-top: 1px solid var(--p-border-color, var(--border-color));
+ margin: 1em 0;
+}
+
+/* Strong and emphasis */
+.comfy-markdown-content strong {
+ font-weight: bold;
+}
+
+.comfy-markdown-content em {
+ font-style: italic;
+}
+
.comfy-modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
@@ -641,3 +883,92 @@ audio.comfy-audio.empty-audio-widget {
width: calc(100vw - env(titlebar-area-width, 100vw));
}
/* End of [Desktop] Electron window specific styles */
+
+/* Vue Node LOD (Level of Detail) System */
+/* These classes control rendering detail based on zoom level */
+
+/* Minimal LOD (zoom <= 0.4) - Title only for performance */
+.lg-node--lod-minimal {
+ min-height: 32px;
+ transition: min-height 0.2s ease;
+ /* Performance optimizations */
+ text-shadow: none;
+ backdrop-filter: none;
+}
+
+.lg-node--lod-minimal .lg-node-body {
+ display: none !important;
+}
+
+/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
+.lg-node--lod-reduced {
+ transition: opacity 0.1s ease;
+ /* Performance optimizations */
+ text-shadow: none;
+}
+
+.lg-node--lod-reduced .lg-widget-label,
+.lg-node--lod-reduced .lg-slot-label {
+ display: none;
+}
+
+.lg-node--lod-reduced .lg-slot {
+ opacity: 0.6;
+ font-size: 0.75rem;
+}
+
+.lg-node--lod-reduced .lg-widget {
+ margin: 2px 0;
+ font-size: 0.875rem;
+}
+
+/* Full LOD (zoom > 0.8) - Complete detail rendering */
+.lg-node--lod-full {
+ /* Uses default styling - no overrides needed */
+}
+
+/* Smooth transitions between LOD levels */
+.lg-node {
+ transition: min-height 0.2s ease;
+ /* Disable text selection on all nodes */
+ user-select: none;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+}
+
+.lg-node .lg-slot,
+.lg-node .lg-widget {
+ transition: opacity 0.1s ease, font-size 0.1s ease;
+}
+
+/* Performance optimization during canvas interaction */
+.transform-pane--interacting .lg-node * {
+ transition: none !important;
+}
+
+.transform-pane--interacting .lg-node {
+ will-change: transform;
+}
+
+/* Global performance optimizations for LOD */
+.lg-node--lod-minimal,
+.lg-node--lod-reduced {
+ /* Remove ALL expensive paint effects */
+ box-shadow: none !important;
+ filter: none !important;
+ backdrop-filter: none !important;
+ text-shadow: none !important;
+ -webkit-mask-image: none !important;
+ mask-image: none !important;
+ clip-path: none !important;
+}
+
+/* Reduce paint complexity for minimal LOD */
+.lg-node--lod-minimal {
+ /* Skip complex borders */
+ border-radius: 0 !important;
+ /* Use solid colors only */
+ background-image: none !important;
+}
+
diff --git a/src/components/common/EditableText.spec.ts b/src/components/common/EditableText.spec.ts
index 2e7b036b5..2d31123b9 100644
--- a/src/components/common/EditableText.spec.ts
+++ b/src/components/common/EditableText.spec.ts
@@ -68,4 +68,73 @@ describe('EditableText', () => {
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
})
+
+ it('cancels editing on escape key', async () => {
+ const wrapper = mountComponent({
+ modelValue: 'Original Text',
+ isEditing: true
+ })
+
+ // Change the input value
+ await wrapper.findComponent(InputText).setValue('Modified Text')
+
+ // Press escape
+ await wrapper.findComponent(InputText).trigger('keyup.escape')
+
+ // Should emit cancel event
+ expect(wrapper.emitted('cancel')).toBeTruthy()
+
+ // Should NOT emit edit event
+ expect(wrapper.emitted('edit')).toBeFalsy()
+
+ // Input value should be reset to original
+ expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
+ 'Original Text'
+ )
+ })
+
+ it('does not save changes when escape is pressed and blur occurs', async () => {
+ const wrapper = mountComponent({
+ modelValue: 'Original Text',
+ isEditing: true
+ })
+
+ // Change the input value
+ await wrapper.findComponent(InputText).setValue('Modified Text')
+
+ // Press escape (which triggers blur internally)
+ await wrapper.findComponent(InputText).trigger('keyup.escape')
+
+ // Manually trigger blur to simulate the blur that happens after escape
+ await wrapper.findComponent(InputText).trigger('blur')
+
+ // Should emit cancel but not edit
+ expect(wrapper.emitted('cancel')).toBeTruthy()
+ expect(wrapper.emitted('edit')).toBeFalsy()
+ })
+
+ it('saves changes on enter but not on escape', async () => {
+ // Test Enter key saves changes
+ const enterWrapper = mountComponent({
+ modelValue: 'Original Text',
+ isEditing: true
+ })
+ await enterWrapper.findComponent(InputText).setValue('Saved Text')
+ await enterWrapper.findComponent(InputText).trigger('keyup.enter')
+ // Trigger blur that happens after enter
+ await enterWrapper.findComponent(InputText).trigger('blur')
+ expect(enterWrapper.emitted('edit')).toBeTruthy()
+ // @ts-expect-error fixme ts strict error
+ expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
+
+ // Test Escape key cancels changes with a fresh wrapper
+ const escapeWrapper = mountComponent({
+ modelValue: 'Original Text',
+ isEditing: true
+ })
+ await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
+ await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
+ expect(escapeWrapper.emitted('cancel')).toBeTruthy()
+ expect(escapeWrapper.emitted('edit')).toBeFalsy()
+ })
})
diff --git a/src/components/common/EditableText.vue b/src/components/common/EditableText.vue
index 16510d3fd..c6fa18a8d 100644
--- a/src/components/common/EditableText.vue
+++ b/src/components/common/EditableText.vue
@@ -14,10 +14,12 @@
fluid
:pt="{
root: {
- onBlur: finishEditing
+ onBlur: finishEditing,
+ ...inputAttrs
}
}"
@keyup.enter="blurInputElement"
+ @keyup.escape="cancelEditing"
@click.stop
/>
@@ -27,21 +29,41 @@
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
-const { modelValue, isEditing = false } = defineProps<{
+const {
+ modelValue,
+ isEditing = false,
+ inputAttrs = {}
+} = defineProps<{
modelValue: string
isEditing?: boolean
+ inputAttrs?: Record
}>()
-const emit = defineEmits(['update:modelValue', 'edit'])
+const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
const inputValue = ref(modelValue)
const inputRef = ref | undefined>()
+const isCanceling = ref(false)
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
- emit('edit', inputValue.value)
+ // Don't save if we're canceling
+ if (!isCanceling.value) {
+ emit('edit', inputValue.value)
+ }
+ isCanceling.value = false
+}
+const cancelEditing = () => {
+ // Set canceling flag to prevent blur from saving
+ isCanceling.value = true
+ // Reset to original value
+ inputValue.value = modelValue
+ // Emit cancel event
+ emit('cancel')
+ // Blur the input to exit edit mode
+ blurInputElement()
}
watch(
() => isEditing,
diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue
index b8b8baa4f..467da7e5d 100644
--- a/src/components/graph/GraphCanvas.vue
+++ b/src/components/graph/GraphCanvas.vue
@@ -31,6 +31,35 @@
class="w-full h-full touch-none"
/>
+
+
+
+
+
+
@@ -39,13 +68,22 @@
-
+
+
diff --git a/src/components/graph/TransformPane.spec.ts b/src/components/graph/TransformPane.spec.ts
new file mode 100644
index 000000000..acfa172ee
--- /dev/null
+++ b/src/components/graph/TransformPane.spec.ts
@@ -0,0 +1,350 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import TransformPane from './TransformPane.vue'
+
+// Mock the transform state composable
+const mockTransformState = {
+ camera: ref({ x: 0, y: 0, z: 1 }),
+ transformStyle: ref({
+ transform: 'scale(1) translate(0px, 0px)',
+ transformOrigin: '0 0'
+ }),
+ syncWithCanvas: vi.fn(),
+ canvasToScreen: vi.fn(),
+ screenToCanvas: vi.fn(),
+ isNodeInViewport: vi.fn()
+}
+
+vi.mock('@/composables/element/useTransformState', () => ({
+ useTransformState: () => mockTransformState
+}))
+
+// Mock requestAnimationFrame/cancelAnimationFrame
+global.requestAnimationFrame = vi.fn((cb) => {
+ setTimeout(cb, 16)
+ return 1
+})
+global.cancelAnimationFrame = vi.fn()
+
+describe('TransformPane', () => {
+ let wrapper: ReturnType
+ let mockCanvas: any
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ // Create mock canvas with LiteGraph interface
+ mockCanvas = {
+ canvas: {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ },
+ ds: {
+ offset: [0, 0],
+ scale: 1
+ }
+ }
+
+ // Reset mock transform state
+ mockTransformState.camera.value = { x: 0, y: 0, z: 1 }
+ mockTransformState.transformStyle.value = {
+ transform: 'scale(1) translate(0px, 0px)',
+ transformOrigin: '0 0'
+ }
+ })
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.unmount()
+ }
+ })
+
+ describe('component mounting', () => {
+ it('should mount successfully with minimal props', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ expect(wrapper.find('.transform-pane').exists()).toBe(true)
+ })
+
+ it('should apply transform style from composable', () => {
+ mockTransformState.transformStyle.value = {
+ transform: 'scale(2) translate(100px, 50px)',
+ transformOrigin: '0 0'
+ }
+
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+ const style = transformPane.attributes('style')
+ expect(style).toContain('transform: scale(2) translate(100px, 50px)')
+ })
+
+ it('should render slot content', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ },
+ slots: {
+ default: 'Test Node
'
+ }
+ })
+
+ expect(wrapper.find('.test-content').exists()).toBe(true)
+ expect(wrapper.find('.test-content').text()).toBe('Test Node')
+ })
+ })
+
+ describe('RAF synchronization', () => {
+ it('should start RAF sync on mount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Should emit RAF status change to true
+ expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
+ expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true])
+ })
+
+ it('should call syncWithCanvas during RAF updates', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Allow RAF to execute
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas)
+ })
+
+ it('should emit transform update timing', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ // Allow RAF to execute
+ await new Promise((resolve) => setTimeout(resolve, 20))
+
+ expect(wrapper.emitted('transformUpdate')).toBeTruthy()
+ const updateEvent = wrapper.emitted('transformUpdate')?.[0]
+ expect(typeof updateEvent?.[0]).toBe('number')
+ expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0)
+ })
+
+ it('should stop RAF sync on unmount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+ wrapper.unmount()
+
+ expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
+ const events = wrapper.emitted('rafStatusChange') as any[]
+ expect(events[events.length - 1]).toEqual([false])
+ expect(global.cancelAnimationFrame).toHaveBeenCalled()
+ })
+ })
+
+ describe('canvas event listeners', () => {
+ it('should add event listeners to canvas on mount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointerdown',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointerup',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
+ 'pointercancel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ })
+
+ it('should remove event listeners on unmount', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ await nextTick()
+ wrapper.unmount()
+
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointerdown',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointerup',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
+ 'pointercancel',
+ expect.any(Function),
+ expect.any(Object)
+ )
+ })
+ })
+
+ describe('interaction state management', () => {
+ it('should apply interacting class during interactions', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ // Simulate interaction start by checking internal state
+ // Note: This tests the CSS class application logic
+ const transformPane = wrapper.find('.transform-pane')
+
+ // Initially should not have interacting class
+ expect(transformPane.classes()).not.toContain(
+ 'transform-pane--interacting'
+ )
+ })
+
+ it('should handle pointer events for node delegation', async () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // Simulate pointer down - we can't test the exact delegation logic
+ // in unit tests due to vue-test-utils limitations, but we can verify
+ // the event handler is set up correctly
+ await transformPane.trigger('pointerdown')
+
+ // The test passes if no errors are thrown during event handling
+ expect(transformPane.exists()).toBe(true)
+ })
+ })
+
+ describe('transform state integration', () => {
+ it('should provide transform utilities to child components', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ // The component should provide transform state via Vue's provide/inject
+ // This is tested indirectly through the composable integration
+ expect(mockTransformState.syncWithCanvas).toBeDefined()
+ expect(mockTransformState.canvasToScreen).toBeDefined()
+ expect(mockTransformState.screenToCanvas).toBeDefined()
+ })
+ })
+
+ describe('error handling', () => {
+ it('should handle null canvas gracefully', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: undefined
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ expect(wrapper.find('.transform-pane').exists()).toBe(true)
+ })
+
+ it('should handle missing canvas properties', () => {
+ const incompleteCanvas = {} as any
+
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: incompleteCanvas
+ }
+ })
+
+ expect(wrapper.exists()).toBe(true)
+ // Should not throw errors during mount
+ })
+ })
+
+ describe('performance optimizations', () => {
+ it('should use contain CSS property for layout optimization', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // This test verifies the CSS contains the performance optimization
+ // Note: In JSDOM, computed styles might not reflect all CSS properties
+ expect(transformPane.element.className).toContain('transform-pane')
+ })
+
+ it('should disable pointer events on container but allow on children', () => {
+ wrapper = mount(TransformPane, {
+ props: {
+ canvas: mockCanvas
+ },
+ slots: {
+ default: 'Test Node
'
+ }
+ })
+
+ const transformPane = wrapper.find('.transform-pane')
+
+ // The CSS should handle pointer events optimization
+ // This is primarily a CSS concern, but we verify the structure
+ expect(transformPane.exists()).toBe(true)
+ expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true)
+ })
+ })
+})
diff --git a/src/components/graph/TransformPane.vue b/src/components/graph/TransformPane.vue
new file mode 100644
index 000000000..266dd0569
--- /dev/null
+++ b/src/components/graph/TransformPane.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/element/useTransformState.ts b/src/composables/element/useTransformState.ts
new file mode 100644
index 000000000..5192bd6df
--- /dev/null
+++ b/src/composables/element/useTransformState.ts
@@ -0,0 +1,242 @@
+/**
+ * Composable for managing transform state synchronized with LiteGraph canvas
+ *
+ * This composable is a critical part of the hybrid rendering architecture that
+ * allows Vue components to render in perfect alignment with LiteGraph's canvas.
+ *
+ * ## Core Concept
+ *
+ * LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
+ * Vue components need to render nodes on top of this canvas. The challenge is
+ * synchronizing the coordinate systems:
+ *
+ * - LiteGraph: Uses canvas coordinates with its own transform matrix
+ * - Vue/DOM: Uses screen coordinates with CSS transforms
+ *
+ * ## Solution: Transform Container Pattern
+ *
+ * Instead of transforming individual nodes (O(n) complexity), we:
+ * 1. Mirror LiteGraph's transform matrix to a single CSS container
+ * 2. Place all Vue nodes as children with simple absolute positioning
+ * 3. Achieve O(1) transform updates regardless of node count
+ *
+ * ## Coordinate Systems
+ *
+ * - **Canvas coordinates**: LiteGraph's internal coordinate system
+ * - **Screen coordinates**: Browser's viewport coordinate system
+ * - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
+ *
+ * ## Performance Benefits
+ *
+ * - GPU acceleration via CSS transforms
+ * - No layout thrashing (only transform changes)
+ * - Efficient viewport culling calculations
+ * - Scales to 1000+ nodes while maintaining 60 FPS
+ *
+ * @example
+ * ```typescript
+ * const { camera, transformStyle, canvasToScreen } = useTransformState()
+ *
+ * // In template
+ *
+ *
+ *
+ *
+ * // Convert coordinates
+ * const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
+ * ```
+ */
+import { computed, reactive, readonly } from 'vue'
+
+import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
+
+export interface Point {
+ x: number
+ y: number
+}
+
+export interface Camera {
+ x: number
+ y: number
+ z: number // scale/zoom
+}
+
+export const useTransformState = () => {
+ // Reactive state mirroring LiteGraph's canvas transform
+ const camera = reactive({
+ x: 0,
+ y: 0,
+ z: 1
+ })
+
+ // Computed transform string for CSS
+ const transformStyle = computed(() => ({
+ transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
+ transformOrigin: '0 0'
+ }))
+
+ /**
+ * Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
+ *
+ * Called every frame via RAF to ensure Vue components stay aligned with canvas.
+ * This is the heart of the hybrid rendering system - it bridges the gap between
+ * LiteGraph's canvas transforms and Vue's reactive system.
+ *
+ * @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
+ */
+ const syncWithCanvas = (canvas: LGraphCanvas) => {
+ if (!canvas || !canvas.ds) return
+
+ // Mirror LiteGraph's transform state to Vue's reactive state
+ // ds.offset = pan offset, ds.scale = zoom level
+ camera.x = canvas.ds.offset[0]
+ camera.y = canvas.ds.offset[1]
+ camera.z = canvas.ds.scale || 1
+ }
+
+ /**
+ * Converts canvas coordinates to screen coordinates
+ *
+ * Applies the same transform that LiteGraph uses for rendering.
+ * Essential for positioning Vue components to align with canvas elements.
+ *
+ * Formula: screen = canvas * scale + offset
+ *
+ * @param point - Point in canvas coordinate system
+ * @returns Point in screen coordinate system
+ */
+ const canvasToScreen = (point: Point): Point => {
+ return {
+ x: point.x * camera.z + camera.x,
+ y: point.y * camera.z + camera.y
+ }
+ }
+
+ /**
+ * Converts screen coordinates to canvas coordinates
+ *
+ * Inverse of canvasToScreen. Useful for hit testing and converting
+ * mouse events back to canvas space.
+ *
+ * Formula: canvas = (screen - offset) / scale
+ *
+ * @param point - Point in screen coordinate system
+ * @returns Point in canvas coordinate system
+ */
+ const screenToCanvas = (point: Point): Point => {
+ return {
+ x: (point.x - camera.x) / camera.z,
+ y: (point.y - camera.y) / camera.z
+ }
+ }
+
+ // Get node's screen bounds for culling
+ const getNodeScreenBounds = (
+ pos: ArrayLike,
+ size: ArrayLike
+ ): DOMRect => {
+ const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
+ const width = size[0] * camera.z
+ const height = size[1] * camera.z
+
+ return new DOMRect(topLeft.x, topLeft.y, width, height)
+ }
+
+ // Helper: Calculate zoom-adjusted margin for viewport culling
+ const calculateAdjustedMargin = (baseMargin: number): number => {
+ if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
+ if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
+ return baseMargin
+ }
+
+ // Helper: Check if node is too small to be visible at current zoom
+ const isNodeTooSmall = (nodeSize: ArrayLike): boolean => {
+ const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
+ return nodeScreenSize < 4
+ }
+
+ // Helper: Calculate expanded viewport bounds with margin
+ const getExpandedViewportBounds = (
+ viewport: { width: number; height: number },
+ margin: number
+ ) => {
+ const marginX = viewport.width * margin
+ const marginY = viewport.height * margin
+ return {
+ left: -marginX,
+ right: viewport.width + marginX,
+ top: -marginY,
+ bottom: viewport.height + marginY
+ }
+ }
+
+ // Helper: Test if node intersects with viewport bounds
+ const testViewportIntersection = (
+ screenPos: { x: number; y: number },
+ nodeSize: ArrayLike,
+ bounds: { left: number; right: number; top: number; bottom: number }
+ ): boolean => {
+ const nodeRight = screenPos.x + nodeSize[0] * camera.z
+ const nodeBottom = screenPos.y + nodeSize[1] * camera.z
+
+ return !(
+ nodeRight < bounds.left ||
+ screenPos.x > bounds.right ||
+ nodeBottom < bounds.top ||
+ screenPos.y > bounds.bottom
+ )
+ }
+
+ // Check if node is within viewport with frustum and size-based culling
+ const isNodeInViewport = (
+ nodePos: ArrayLike,
+ nodeSize: ArrayLike,
+ viewport: { width: number; height: number },
+ margin: number = 0.2
+ ): boolean => {
+ // Early exit for tiny nodes
+ if (isNodeTooSmall(nodeSize)) return false
+
+ const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
+ const adjustedMargin = calculateAdjustedMargin(margin)
+ const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
+
+ return testViewportIntersection(screenPos, nodeSize, bounds)
+ }
+
+ // Get viewport bounds in canvas coordinates (for spatial index queries)
+ const getViewportBounds = (
+ viewport: { width: number; height: number },
+ margin: number = 0.2
+ ) => {
+ const marginX = viewport.width * margin
+ const marginY = viewport.height * margin
+
+ const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
+ const bottomRight = screenToCanvas({
+ x: viewport.width + marginX,
+ y: viewport.height + marginY
+ })
+
+ return {
+ x: topLeft.x,
+ y: topLeft.y,
+ width: bottomRight.x - topLeft.x,
+ height: bottomRight.y - topLeft.y
+ }
+ }
+
+ return {
+ camera: readonly(camera),
+ transformStyle,
+ syncWithCanvas,
+ canvasToScreen,
+ screenToCanvas,
+ getNodeScreenBounds,
+ isNodeInViewport,
+ getViewportBounds
+ }
+}
diff --git a/src/composables/graph/useCanvasTransformSync.ts b/src/composables/graph/useCanvasTransformSync.ts
new file mode 100644
index 000000000..3e382492b
--- /dev/null
+++ b/src/composables/graph/useCanvasTransformSync.ts
@@ -0,0 +1,115 @@
+import { onUnmounted, ref } from 'vue'
+
+import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
+
+export interface CanvasTransformSyncOptions {
+ /**
+ * Whether to automatically start syncing when canvas is available
+ * @default true
+ */
+ autoStart?: boolean
+}
+
+export interface CanvasTransformSyncCallbacks {
+ /**
+ * Called when sync starts
+ */
+ onStart?: () => void
+ /**
+ * Called after each sync update with timing information
+ */
+ onUpdate?: (duration: number) => void
+ /**
+ * Called when sync stops
+ */
+ onStop?: () => void
+}
+
+/**
+ * Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
+ *
+ * This composable provides a clean way to sync Vue transform state with LiteGraph canvas
+ * on every frame. It handles RAF lifecycle management, provides performance timing,
+ * and ensures proper cleanup.
+ *
+ * The sync function typically reads canvas.ds (draw state) properties like offset and scale
+ * to keep Vue components aligned with the canvas coordinate system.
+ *
+ * @example
+ * ```ts
+ * const { isActive, startSync, stopSync } = useCanvasTransformSync(
+ * canvas,
+ * (canvas) => syncWithCanvas(canvas),
+ * {
+ * onStart: () => emit('rafStatusChange', true),
+ * onUpdate: (time) => emit('transformUpdate', time),
+ * onStop: () => emit('rafStatusChange', false)
+ * }
+ * )
+ * ```
+ */
+export function useCanvasTransformSync(
+ canvas: LGraphCanvas | undefined | null,
+ syncFn: (canvas: LGraphCanvas) => void,
+ callbacks: CanvasTransformSyncCallbacks = {},
+ options: CanvasTransformSyncOptions = {}
+) {
+ const { autoStart = true } = options
+ const { onStart, onUpdate, onStop } = callbacks
+
+ const isActive = ref(false)
+ let rafId: number | null = null
+
+ const startSync = () => {
+ if (isActive.value || !canvas) return
+
+ isActive.value = true
+ onStart?.()
+
+ const sync = () => {
+ if (!isActive.value || !canvas) return
+
+ try {
+ const startTime = performance.now()
+ syncFn(canvas)
+ const endTime = performance.now()
+
+ onUpdate?.(endTime - startTime)
+ } catch (error) {
+ console.warn('Canvas transform sync error:', error)
+ }
+
+ rafId = requestAnimationFrame(sync)
+ }
+
+ sync()
+ }
+
+ const stopSync = () => {
+ if (!isActive.value) return
+
+ if (rafId !== null) {
+ cancelAnimationFrame(rafId)
+ rafId = null
+ }
+
+ isActive.value = false
+ onStop?.()
+ }
+
+ // Auto-start if canvas is available and autoStart is enabled
+ if (autoStart && canvas) {
+ startSync()
+ }
+
+ // Clean up on unmount
+ onUnmounted(() => {
+ stopSync()
+ })
+
+ return {
+ isActive,
+ startSync,
+ stopSync
+ }
+}
diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts
new file mode 100644
index 000000000..a5079c453
--- /dev/null
+++ b/src/composables/graph/useGraphNodeManager.ts
@@ -0,0 +1,813 @@
+/**
+ * Vue node lifecycle management for LiteGraph integration
+ * Provides event-driven reactivity with performance optimizations
+ */
+import { nextTick, reactive, readonly } from 'vue'
+
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { LayoutSource } from '@/renderer/core/layout/types'
+import type { WidgetValue } from '@/types/simplifiedWidget'
+import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
+
+import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
+import { type Bounds, QuadTree } from '../../utils/spatial/QuadTree'
+
+export interface NodeState {
+ visible: boolean
+ dirty: boolean
+ lastUpdate: number
+ culled: boolean
+}
+
+export interface NodeMetadata {
+ lastRenderTime: number
+ cachedBounds: DOMRect | null
+ lodLevel: 'high' | 'medium' | 'low'
+ spatialIndex?: QuadTree
+}
+
+export interface PerformanceMetrics {
+ fps: number
+ frameTime: number
+ updateTime: number
+ nodeCount: number
+ culledCount: number
+ callbackUpdateCount: number
+ rafUpdateCount: number
+ adaptiveQuality: boolean
+}
+
+export interface SafeWidgetData {
+ name: string
+ type: string
+ value: WidgetValue
+ options?: Record
+ callback?: ((value: unknown) => void) | undefined
+}
+
+export interface VueNodeData {
+ id: string
+ title: string
+ type: string
+ mode: number
+ selected: boolean
+ executing: boolean
+ widgets?: SafeWidgetData[]
+ inputs?: unknown[]
+ outputs?: unknown[]
+ flags?: {
+ collapsed?: boolean
+ }
+}
+
+export interface SpatialMetrics {
+ queryTime: number
+ nodesInIndex: number
+}
+
+export interface GraphNodeManager {
+ // Reactive state - safe data extracted from LiteGraph nodes
+ vueNodeData: ReadonlyMap
+ nodeState: ReadonlyMap
+ nodePositions: ReadonlyMap
+ nodeSizes: ReadonlyMap
+
+ // Access to original LiteGraph nodes (non-reactive)
+ getNode(id: string): LGraphNode | undefined
+
+ // Lifecycle methods
+ setupEventListeners(): () => void
+ cleanup(): void
+
+ // Update methods
+ scheduleUpdate(
+ nodeId?: string,
+ priority?: 'critical' | 'normal' | 'low'
+ ): void
+ forceSync(): void
+ detectChangesInRAF(): void
+
+ // Spatial queries
+ getVisibleNodeIds(viewportBounds: Bounds): Set
+
+ // Performance
+ performanceMetrics: PerformanceMetrics
+ spatialMetrics: SpatialMetrics
+
+ // Debug
+ getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
+}
+
+export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
+ // Get layout mutations composable
+ const { moveNode, resizeNode, createNode, deleteNode, setSource } =
+ useLayoutMutations()
+ // Safe reactive data extracted from LiteGraph nodes
+ const vueNodeData = reactive(new Map())
+ const nodeState = reactive(new Map())
+ const nodePositions = reactive(new Map())
+ const nodeSizes = reactive(
+ new Map()
+ )
+
+ // Non-reactive storage for original LiteGraph nodes
+ const nodeRefs = new Map()
+
+ // WeakMap for heavy data that auto-GCs when nodes are removed
+ const nodeMetadata = new WeakMap()
+
+ // Performance tracking
+ const performanceMetrics = reactive({
+ fps: 0,
+ frameTime: 0,
+ updateTime: 0,
+ nodeCount: 0,
+ culledCount: 0,
+ callbackUpdateCount: 0,
+ rafUpdateCount: 0,
+ adaptiveQuality: false
+ })
+
+ // Spatial indexing using QuadTree
+ const spatialIndex = new QuadTree(
+ { x: -10000, y: -10000, width: 20000, height: 20000 },
+ { maxDepth: 6, maxItemsPerNode: 4 }
+ )
+ let lastSpatialQueryTime = 0
+
+ // Spatial metrics
+ const spatialMetrics = reactive({
+ queryTime: 0,
+ nodesInIndex: 0
+ })
+
+ // Update batching
+ const pendingUpdates = new Set()
+ const criticalUpdates = new Set()
+ const lowPriorityUpdates = new Set()
+ let updateScheduled = false
+ let batchTimeoutId: number | null = null
+
+ // Change detection state
+ const lastNodesSnapshot = new Map<
+ string,
+ { pos: [number, number]; size: [number, number] }
+ >()
+
+ const attachMetadata = (node: LGraphNode) => {
+ nodeMetadata.set(node, {
+ lastRenderTime: performance.now(),
+ cachedBounds: null,
+ lodLevel: 'high',
+ spatialIndex: undefined
+ })
+ }
+
+ // Extract safe data from LiteGraph node for Vue consumption
+ const extractVueNodeData = (node: LGraphNode): VueNodeData => {
+ // Extract safe widget data
+ const safeWidgets = node.widgets?.map((widget) => {
+ try {
+ // TODO: Use widget.getReactiveData() once TypeScript types are updated
+ let value = widget.value
+
+ // For combo widgets, if value is undefined, use the first option as default
+ if (
+ value === undefined &&
+ widget.type === 'combo' &&
+ widget.options?.values &&
+ Array.isArray(widget.options.values) &&
+ widget.options.values.length > 0
+ ) {
+ value = widget.options.values[0]
+ }
+
+ return {
+ name: widget.name,
+ type: widget.type,
+ value: value,
+ options: widget.options ? { ...widget.options } : undefined,
+ callback: widget.callback
+ }
+ } catch (error) {
+ return {
+ name: widget.name || 'unknown',
+ type: widget.type || 'text',
+ value: undefined, // Already a valid WidgetValue
+ options: undefined,
+ callback: undefined
+ }
+ }
+ })
+
+ return {
+ id: String(node.id),
+ title: node.title || 'Untitled',
+ type: node.type || 'Unknown',
+ mode: node.mode || 0,
+ selected: node.selected || false,
+ executing: false, // Will be updated separately based on execution state
+ widgets: safeWidgets,
+ inputs: node.inputs ? [...node.inputs] : undefined,
+ outputs: node.outputs ? [...node.outputs] : undefined,
+ flags: node.flags ? { ...node.flags } : undefined
+ }
+ }
+
+ // Get access to original LiteGraph node (non-reactive)
+ const getNode = (id: string): LGraphNode | undefined => {
+ return nodeRefs.get(id)
+ }
+
+ /**
+ * Validates that a value is a valid WidgetValue type
+ */
+ const validateWidgetValue = (value: unknown): WidgetValue => {
+ if (value === null || value === undefined || value === void 0) {
+ return undefined
+ }
+ if (
+ typeof value === 'string' ||
+ typeof value === 'number' ||
+ typeof value === 'boolean'
+ ) {
+ return value
+ }
+ if (typeof value === 'object') {
+ // Check if it's a File array
+ if (Array.isArray(value) && value.every((item) => item instanceof File)) {
+ return value as File[]
+ }
+ // Otherwise it's a generic object
+ return value as object
+ }
+ // If none of the above, return undefined
+ console.warn(`Invalid widget value type: ${typeof value}`, value)
+ return undefined
+ }
+
+ /**
+ * Updates Vue state when widget values change
+ */
+ const updateVueWidgetState = (
+ nodeId: string,
+ widgetName: string,
+ value: unknown
+ ): void => {
+ try {
+ const currentData = vueNodeData.get(nodeId)
+ if (!currentData?.widgets) return
+
+ const updatedWidgets = currentData.widgets.map((w) =>
+ w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
+ )
+ vueNodeData.set(nodeId, {
+ ...currentData,
+ widgets: updatedWidgets
+ })
+ performanceMetrics.callbackUpdateCount++
+ } catch (error) {
+ // Ignore widget update errors to prevent cascade failures
+ }
+ }
+
+ /**
+ * Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
+ */
+ const createWrappedWidgetCallback = (
+ widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
+ originalCallback: ((value: unknown) => void) | undefined,
+ nodeId: string
+ ) => {
+ let updateInProgress = false
+
+ return (value: unknown) => {
+ if (updateInProgress) return
+ updateInProgress = true
+
+ try {
+ // 1. Update the widget value in LiteGraph (critical for LiteGraph state)
+ // Validate that the value is of an acceptable type
+ if (
+ value !== null &&
+ value !== undefined &&
+ typeof value !== 'string' &&
+ typeof value !== 'number' &&
+ typeof value !== 'boolean' &&
+ typeof value !== 'object'
+ ) {
+ console.warn(`Invalid widget value type: ${typeof value}`)
+ updateInProgress = false
+ return
+ }
+
+ // Always update widget.value to ensure sync
+ widget.value = value
+
+ // 2. Call the original callback if it exists
+ if (originalCallback) {
+ originalCallback.call(widget, value)
+ }
+
+ // 3. Update Vue state to maintain synchronization
+ updateVueWidgetState(nodeId, widget.name, value)
+ } finally {
+ updateInProgress = false
+ }
+ }
+ }
+
+ /**
+ * Sets up widget callbacks for a node - now with reduced nesting
+ */
+ const setupNodeWidgetCallbacks = (node: LGraphNode) => {
+ if (!node.widgets) return
+
+ const nodeId = String(node.id)
+
+ node.widgets.forEach((widget) => {
+ const originalCallback = widget.callback
+ widget.callback = createWrappedWidgetCallback(
+ widget,
+ originalCallback,
+ nodeId
+ )
+ })
+ }
+
+ // Uncomment when needed for future features
+ // const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
+ // let metadata = nodeMetadata.get(node)
+ // if (!metadata) {
+ // attachMetadata(node)
+ // metadata = nodeMetadata.get(node)!
+ // }
+ // return metadata
+ // }
+
+ const scheduleUpdate = (
+ nodeId?: string,
+ priority: 'critical' | 'normal' | 'low' = 'normal'
+ ) => {
+ if (nodeId) {
+ const state = nodeState.get(nodeId)
+ if (state) state.dirty = true
+
+ // Priority queuing
+ if (priority === 'critical') {
+ criticalUpdates.add(nodeId)
+ flush() // Immediate flush for critical updates
+ return
+ } else if (priority === 'low') {
+ lowPriorityUpdates.add(nodeId)
+ } else {
+ pendingUpdates.add(nodeId)
+ }
+ }
+
+ if (!updateScheduled) {
+ updateScheduled = true
+
+ // Adaptive batching strategy
+ if (pendingUpdates.size > 10) {
+ // Many updates - batch in nextTick
+ void nextTick(() => flush())
+ } else {
+ // Few updates - small delay for more batching
+ batchTimeoutId = window.setTimeout(() => flush(), 4)
+ }
+ }
+ }
+
+ const flush = () => {
+ const startTime = performance.now()
+
+ if (batchTimeoutId !== null) {
+ clearTimeout(batchTimeoutId)
+ batchTimeoutId = null
+ }
+
+ // Clear all pending updates
+ criticalUpdates.clear()
+ pendingUpdates.clear()
+ lowPriorityUpdates.clear()
+ updateScheduled = false
+
+ // Sync with graph state
+ syncWithGraph()
+
+ const endTime = performance.now()
+ performanceMetrics.updateTime = endTime - startTime
+ }
+
+ const syncWithGraph = () => {
+ if (!graph?._nodes) return
+
+ const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
+
+ // Remove deleted nodes
+ for (const id of Array.from(vueNodeData.keys())) {
+ if (!currentNodes.has(id)) {
+ nodeRefs.delete(id)
+ vueNodeData.delete(id)
+ nodeState.delete(id)
+ nodePositions.delete(id)
+ nodeSizes.delete(id)
+ lastNodesSnapshot.delete(id)
+ spatialIndex.remove(id)
+ }
+ }
+
+ // Add/update existing nodes
+ graph._nodes.forEach((node) => {
+ const id = String(node.id)
+
+ // Store non-reactive reference
+ nodeRefs.set(id, node)
+
+ // Set up widget callbacks BEFORE extracting data (critical order)
+ setupNodeWidgetCallbacks(node)
+
+ // Extract and store safe data for Vue
+ vueNodeData.set(id, extractVueNodeData(node))
+
+ if (!nodeState.has(id)) {
+ nodeState.set(id, {
+ visible: true,
+ dirty: false,
+ lastUpdate: performance.now(),
+ culled: false
+ })
+ nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
+ nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
+ attachMetadata(node)
+
+ // Add to spatial index
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ spatialIndex.insert(id, bounds, id)
+ }
+ })
+
+ // Update performance metrics
+ performanceMetrics.nodeCount = vueNodeData.size
+ performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
+ (s) => s.culled
+ ).length
+ }
+
+ // Most performant: Direct position sync without re-setting entire node
+ // Query visible nodes using QuadTree spatial index
+ const getVisibleNodeIds = (viewportBounds: Bounds): Set => {
+ const startTime = performance.now()
+
+ // Use QuadTree for fast spatial query
+ const results: string[] = spatialIndex.query(viewportBounds)
+ const visibleIds = new Set(results)
+
+ lastSpatialQueryTime = performance.now() - startTime
+ spatialMetrics.queryTime = lastSpatialQueryTime
+
+ return visibleIds
+ }
+
+ /**
+ * Detects position changes for a single node and updates reactive state
+ */
+ const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
+ const currentPos = nodePositions.get(id)
+
+ if (
+ !currentPos ||
+ currentPos.x !== node.pos[0] ||
+ currentPos.y !== node.pos[1]
+ ) {
+ nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
+
+ // Push position change to layout store
+ // Source is already set to 'canvas' in detectChangesInRAF
+ void moveNode(id, { x: node.pos[0], y: node.pos[1] })
+
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Detects size changes for a single node and updates reactive state
+ */
+ const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
+ const currentSize = nodeSizes.get(id)
+
+ if (
+ !currentSize ||
+ currentSize.width !== node.size[0] ||
+ currentSize.height !== node.size[1]
+ ) {
+ nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
+
+ // Push size change to layout store
+ // Source is already set to 'canvas' in detectChangesInRAF
+ void resizeNode(id, {
+ width: node.size[0],
+ height: node.size[1]
+ })
+
+ return true
+ }
+ return false
+ }
+
+ /**
+ * Updates spatial index for a node if bounds changed
+ */
+ const updateSpatialIndex = (node: LGraphNode, id: string): void => {
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ spatialIndex.update(id, bounds)
+ }
+
+ /**
+ * Updates performance metrics after change detection
+ */
+ const updatePerformanceMetrics = (
+ startTime: number,
+ positionUpdates: number,
+ sizeUpdates: number
+ ): void => {
+ const endTime = performance.now()
+ performanceMetrics.updateTime = endTime - startTime
+ performanceMetrics.nodeCount = vueNodeData.size
+ performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
+ (state) => state.culled
+ ).length
+ spatialMetrics.nodesInIndex = spatialIndex.size
+
+ if (positionUpdates > 0 || sizeUpdates > 0) {
+ performanceMetrics.rafUpdateCount++
+ }
+ }
+
+ /**
+ * Main RAF change detection function
+ */
+ const detectChangesInRAF = () => {
+ const startTime = performance.now()
+
+ if (!graph?._nodes) return
+
+ let positionUpdates = 0
+ let sizeUpdates = 0
+
+ // Set source for all canvas-driven updates
+ setSource(LayoutSource.Canvas)
+
+ // Process each node for changes
+ for (const node of graph._nodes) {
+ const id = String(node.id)
+
+ const posChanged = detectPositionChanges(node, id)
+ const sizeChanged = detectSizeChanges(node, id)
+
+ if (posChanged) positionUpdates++
+ if (sizeChanged) sizeUpdates++
+
+ // Update spatial index if geometry changed
+ if (posChanged || sizeChanged) {
+ updateSpatialIndex(node, id)
+ }
+ }
+
+ updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
+ }
+
+ /**
+ * Handles node addition to the graph - sets up Vue state and spatial indexing
+ */
+ const handleNodeAdded = (
+ node: LGraphNode,
+ originalCallback?: (node: LGraphNode) => void
+ ) => {
+ const id = String(node.id)
+
+ // Store non-reactive reference to original node
+ nodeRefs.set(id, node)
+
+ // Set up widget callbacks BEFORE extracting data (critical order)
+ setupNodeWidgetCallbacks(node)
+
+ // Extract safe data for Vue (now with proper callbacks)
+ vueNodeData.set(id, extractVueNodeData(node))
+
+ // Set up reactive tracking state
+ nodeState.set(id, {
+ visible: true,
+ dirty: false,
+ lastUpdate: performance.now(),
+ culled: false
+ })
+ nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
+ nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
+ attachMetadata(node)
+
+ // Add to spatial index for viewport culling
+ const bounds: Bounds = {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ spatialIndex.insert(id, bounds, id)
+
+ // Add node to layout store
+ setSource(LayoutSource.Canvas)
+ void createNode(id, {
+ position: { x: node.pos[0], y: node.pos[1] },
+ size: { width: node.size[0], height: node.size[1] },
+ zIndex: node.order || 0,
+ visible: true
+ })
+
+ // Call original callback if provided
+ if (originalCallback) {
+ void originalCallback(node)
+ }
+ }
+
+ /**
+ * Handles node removal from the graph - cleans up all references
+ */
+ const handleNodeRemoved = (
+ node: LGraphNode,
+ originalCallback?: (node: LGraphNode) => void
+ ) => {
+ const id = String(node.id)
+
+ // Remove from spatial index
+ spatialIndex.remove(id)
+
+ // Remove node from layout store
+ setSource(LayoutSource.Canvas)
+ void deleteNode(id)
+
+ // Clean up all tracking references
+ nodeRefs.delete(id)
+ vueNodeData.delete(id)
+ nodeState.delete(id)
+ nodePositions.delete(id)
+ nodeSizes.delete(id)
+ lastNodesSnapshot.delete(id)
+
+ // Call original callback if provided
+ if (originalCallback) {
+ originalCallback(node)
+ }
+ }
+
+ /**
+ * Creates cleanup function for event listeners and state
+ */
+ const createCleanupFunction = (
+ originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
+ originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
+ originalOnTrigger: ((action: string, param: unknown) => void) | undefined
+ ) => {
+ return () => {
+ // Restore original callbacks
+ graph.onNodeAdded = originalOnNodeAdded || undefined
+ graph.onNodeRemoved = originalOnNodeRemoved || undefined
+ graph.onTrigger = originalOnTrigger || undefined
+
+ // Clear pending updates
+ if (batchTimeoutId !== null) {
+ clearTimeout(batchTimeoutId)
+ batchTimeoutId = null
+ }
+
+ // Clear all state maps
+ nodeRefs.clear()
+ vueNodeData.clear()
+ nodeState.clear()
+ nodePositions.clear()
+ nodeSizes.clear()
+ lastNodesSnapshot.clear()
+ pendingUpdates.clear()
+ criticalUpdates.clear()
+ lowPriorityUpdates.clear()
+ spatialIndex.clear()
+ }
+ }
+
+ /**
+ * Sets up event listeners - now simplified with extracted handlers
+ */
+ const setupEventListeners = (): (() => void) => {
+ // Store original callbacks
+ const originalOnNodeAdded = graph.onNodeAdded
+ const originalOnNodeRemoved = graph.onNodeRemoved
+ const originalOnTrigger = graph.onTrigger
+
+ // Set up graph event handlers
+ graph.onNodeAdded = (node: LGraphNode) => {
+ handleNodeAdded(node, originalOnNodeAdded)
+ }
+
+ graph.onNodeRemoved = (node: LGraphNode) => {
+ handleNodeRemoved(node, originalOnNodeRemoved)
+ }
+
+ // Listen for property change events from instrumented nodes
+ graph.onTrigger = (action: string, param: unknown) => {
+ if (
+ action === 'node:property:changed' &&
+ param &&
+ typeof param === 'object'
+ ) {
+ const event = param as {
+ nodeId: string | number
+ property: string
+ oldValue: unknown
+ newValue: unknown
+ }
+
+ const nodeId = String(event.nodeId)
+ const currentData = vueNodeData.get(nodeId)
+
+ if (currentData) {
+ if (event.property === 'title') {
+ vueNodeData.set(nodeId, {
+ ...currentData,
+ title: String(event.newValue)
+ })
+ } else if (event.property === 'flags.collapsed') {
+ vueNodeData.set(nodeId, {
+ ...currentData,
+ flags: {
+ ...currentData.flags,
+ collapsed: Boolean(event.newValue)
+ }
+ })
+ }
+ }
+ }
+
+ // Call original trigger handler if it exists
+ if (originalOnTrigger) {
+ originalOnTrigger(action, param)
+ }
+ }
+
+ // Initialize state
+ syncWithGraph()
+
+ // Return cleanup function
+ return createCleanupFunction(
+ originalOnNodeAdded || undefined,
+ originalOnNodeRemoved || undefined,
+ originalOnTrigger || undefined
+ )
+ }
+
+ // Set up event listeners immediately
+ const cleanup = setupEventListeners()
+
+ // Process any existing nodes after event listeners are set up
+ if (graph._nodes && graph._nodes.length > 0) {
+ graph._nodes.forEach((node: LGraphNode) => {
+ if (graph.onNodeAdded) {
+ graph.onNodeAdded(node)
+ }
+ })
+ }
+
+ return {
+ vueNodeData: readonly(vueNodeData) as ReadonlyMap,
+ nodeState: readonly(nodeState) as ReadonlyMap,
+ nodePositions: readonly(nodePositions) as ReadonlyMap<
+ string,
+ { x: number; y: number }
+ >,
+ nodeSizes: readonly(nodeSizes) as ReadonlyMap<
+ string,
+ { width: number; height: number }
+ >,
+ getNode,
+ setupEventListeners,
+ cleanup,
+ scheduleUpdate,
+ forceSync: syncWithGraph,
+ detectChangesInRAF,
+ getVisibleNodeIds,
+ performanceMetrics,
+ spatialMetrics: readonly(spatialMetrics),
+ getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
+ }
+}
diff --git a/src/composables/graph/useSpatialIndex.ts b/src/composables/graph/useSpatialIndex.ts
new file mode 100644
index 000000000..997e331f7
--- /dev/null
+++ b/src/composables/graph/useSpatialIndex.ts
@@ -0,0 +1,198 @@
+/**
+ * Composable for spatial indexing of nodes using QuadTree
+ * Integrates with useGraphNodeManager for efficient viewport culling
+ */
+import { useDebounceFn } from '@vueuse/core'
+import { computed, reactive, ref } from 'vue'
+
+import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree'
+
+export interface SpatialIndexOptions {
+ worldBounds?: Bounds
+ maxDepth?: number
+ maxItemsPerNode?: number
+ updateDebounceMs?: number
+}
+
+interface SpatialMetrics {
+ queryTime: number
+ totalNodes: number
+ visibleNodes: number
+ treeDepth: number
+ rebuildCount: number
+}
+
+export const useSpatialIndex = (options: SpatialIndexOptions = {}) => {
+ // Default world bounds (can be expanded dynamically)
+ const defaultBounds: Bounds = {
+ x: -10000,
+ y: -10000,
+ width: 20000,
+ height: 20000
+ }
+
+ // QuadTree instance
+ const quadTree = ref | null>(null)
+
+ // Performance metrics
+ const metrics = reactive({
+ queryTime: 0,
+ totalNodes: 0,
+ visibleNodes: 0,
+ treeDepth: 0,
+ rebuildCount: 0
+ })
+
+ // Initialize QuadTree
+ const initialize = (bounds: Bounds = defaultBounds) => {
+ quadTree.value = new QuadTree(bounds, {
+ maxDepth: options.maxDepth ?? 6,
+ maxItemsPerNode: options.maxItemsPerNode ?? 4
+ })
+ metrics.rebuildCount++
+ }
+
+ // Add or update node in spatial index
+ const updateNode = (
+ nodeId: string,
+ position: { x: number; y: number },
+ size: { width: number; height: number }
+ ) => {
+ if (!quadTree.value) {
+ initialize()
+ }
+
+ const bounds: Bounds = {
+ x: position.x,
+ y: position.y,
+ width: size.width,
+ height: size.height
+ }
+
+ // Use insert instead of update - insert handles both new and existing nodes
+ quadTree.value!.insert(nodeId, bounds, nodeId)
+ metrics.totalNodes = quadTree.value!.size
+ }
+
+ // Batch update for multiple nodes
+ const batchUpdate = (
+ updates: Array<{
+ id: string
+ position: { x: number; y: number }
+ size: { width: number; height: number }
+ }>
+ ) => {
+ if (!quadTree.value) {
+ initialize()
+ }
+
+ for (const update of updates) {
+ const bounds: Bounds = {
+ x: update.position.x,
+ y: update.position.y,
+ width: update.size.width,
+ height: update.size.height
+ }
+ // Use insert instead of update - insert handles both new and existing nodes
+ quadTree.value!.insert(update.id, bounds, update.id)
+ }
+
+ metrics.totalNodes = quadTree.value!.size
+ }
+
+ // Remove node from spatial index
+ const removeNode = (nodeId: string) => {
+ if (!quadTree.value) return
+
+ quadTree.value.remove(nodeId)
+ metrics.totalNodes = quadTree.value.size
+ }
+
+ // Query nodes within viewport bounds
+ const queryViewport = (viewportBounds: Bounds): string[] => {
+ if (!quadTree.value) return []
+
+ const startTime = performance.now()
+ const nodeIds = quadTree.value.query(viewportBounds)
+ const queryTime = performance.now() - startTime
+
+ metrics.queryTime = queryTime
+ metrics.visibleNodes = nodeIds.length
+
+ return nodeIds
+ }
+
+ // Get nodes within a radius (for proximity queries)
+ const queryRadius = (
+ center: { x: number; y: number },
+ radius: number
+ ): string[] => {
+ if (!quadTree.value) return []
+
+ const bounds: Bounds = {
+ x: center.x - radius,
+ y: center.y - radius,
+ width: radius * 2,
+ height: radius * 2
+ }
+
+ return quadTree.value.query(bounds)
+ }
+
+ // Clear all nodes
+ const clear = () => {
+ if (!quadTree.value) return
+
+ quadTree.value.clear()
+ metrics.totalNodes = 0
+ metrics.visibleNodes = 0
+ }
+
+ // Rebuild tree (useful after major layout changes)
+ const rebuild = (
+ nodes: Map<
+ string,
+ {
+ position: { x: number; y: number }
+ size: { width: number; height: number }
+ }
+ >
+ ) => {
+ initialize()
+
+ const updates = Array.from(nodes.entries()).map(([id, data]) => ({
+ id,
+ position: data.position,
+ size: data.size
+ }))
+
+ batchUpdate(updates)
+ }
+
+ // Debounced update for performance
+ const debouncedUpdateNode = useDebounceFn(
+ updateNode,
+ options.updateDebounceMs ?? 16
+ )
+
+ return {
+ // Core functions
+ initialize,
+ updateNode,
+ batchUpdate,
+ removeNode,
+ queryViewport,
+ queryRadius,
+ clear,
+ rebuild,
+
+ // Debounced version for high-frequency updates
+ debouncedUpdateNode,
+
+ // Metrics
+ metrics: computed(() => metrics),
+
+ // Direct access to QuadTree (for advanced usage)
+ quadTree: computed(() => quadTree.value)
+ }
+}
diff --git a/src/composables/graph/useTransformSettling.ts b/src/composables/graph/useTransformSettling.ts
new file mode 100644
index 000000000..669cfceaa
--- /dev/null
+++ b/src/composables/graph/useTransformSettling.ts
@@ -0,0 +1,151 @@
+import { useDebounceFn, useEventListener, useThrottleFn } from '@vueuse/core'
+import { ref } from 'vue'
+import type { MaybeRefOrGetter } from 'vue'
+
+export interface TransformSettlingOptions {
+ /**
+ * Delay in ms before transform is considered "settled" after last interaction
+ * @default 200
+ */
+ settleDelay?: number
+ /**
+ * Whether to track both zoom (wheel) and pan (pointer drag) interactions
+ * @default false
+ */
+ trackPan?: boolean
+ /**
+ * Throttle delay for high-frequency pointermove events (only used when trackPan is true)
+ * @default 16 (~60fps)
+ */
+ pointerMoveThrottle?: number
+ /**
+ * Whether to use passive event listeners (better performance but can't preventDefault)
+ * @default true
+ */
+ passive?: boolean
+}
+
+/**
+ * Tracks when canvas transforms (zoom/pan) are actively changing vs settled.
+ *
+ * This composable helps optimize rendering quality during transformations.
+ * When the user is actively zooming or panning, we can reduce rendering quality
+ * for better performance. Once the transform "settles" (stops changing), we can
+ * trigger high-quality re-rasterization.
+ *
+ * The settling concept prevents constant quality switching during interactions
+ * by waiting for a period of inactivity before considering the transform complete.
+ *
+ * Uses VueUse's useEventListener for automatic cleanup and useDebounceFn for
+ * efficient settle detection.
+ *
+ * @example
+ * ```ts
+ * const { isTransforming } = useTransformSettling(canvasRef, {
+ * settleDelay: 200,
+ * trackPan: true
+ * })
+ *
+ * // Use in CSS classes or rendering logic
+ * const cssClass = computed(() => ({
+ * 'low-quality': isTransforming.value,
+ * 'high-quality': !isTransforming.value
+ * }))
+ * ```
+ */
+export function useTransformSettling(
+ target: MaybeRefOrGetter,
+ options: TransformSettlingOptions = {}
+) {
+ const {
+ settleDelay = 200,
+ trackPan = false,
+ pointerMoveThrottle = 16,
+ passive = true
+ } = options
+
+ const isTransforming = ref(false)
+ let isPanning = false
+
+ /**
+ * Mark transform as active
+ */
+ const markTransformActive = () => {
+ isTransforming.value = true
+ }
+
+ /**
+ * Mark transform as settled (debounced)
+ */
+ const markTransformSettled = useDebounceFn(() => {
+ isTransforming.value = false
+ }, settleDelay)
+
+ /**
+ * Handle any transform event - mark active then queue settle
+ */
+ const handleTransformEvent = () => {
+ markTransformActive()
+ void markTransformSettled()
+ }
+
+ // Wheel handler
+ const handleWheel = () => {
+ handleTransformEvent()
+ }
+
+ // Pointer handlers for panning
+ const handlePointerDown = () => {
+ if (trackPan) {
+ isPanning = true
+ handleTransformEvent()
+ }
+ }
+
+ // Throttled pointer move handler for performance
+ const handlePointerMove = trackPan
+ ? useThrottleFn(() => {
+ if (isPanning) {
+ handleTransformEvent()
+ }
+ }, pointerMoveThrottle)
+ : undefined
+
+ const handlePointerEnd = () => {
+ if (trackPan) {
+ isPanning = false
+ // Don't immediately stop - let the debounced settle handle it
+ }
+ }
+
+ // Register event listeners with auto-cleanup
+ useEventListener(target, 'wheel', handleWheel, {
+ capture: true,
+ passive
+ })
+
+ if (trackPan) {
+ useEventListener(target, 'pointerdown', handlePointerDown, {
+ capture: true
+ })
+
+ if (handlePointerMove) {
+ useEventListener(target, 'pointermove', handlePointerMove, {
+ capture: true,
+ passive
+ })
+ }
+
+ useEventListener(target, 'pointerup', handlePointerEnd, {
+ capture: true
+ })
+
+ useEventListener(target, 'pointercancel', handlePointerEnd, {
+ capture: true
+ })
+ }
+
+ return {
+ isTransforming
+ }
+}
diff --git a/src/composables/graph/useWidgetValue.ts b/src/composables/graph/useWidgetValue.ts
new file mode 100644
index 000000000..1cf2fb353
--- /dev/null
+++ b/src/composables/graph/useWidgetValue.ts
@@ -0,0 +1,155 @@
+/**
+ * Composable for managing widget value synchronization between Vue and LiteGraph
+ * Provides consistent pattern for immediate UI updates and LiteGraph callbacks
+ */
+import { type Ref, ref, watch } from 'vue'
+
+import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
+
+export interface UseWidgetValueOptions<
+ T extends WidgetValue = WidgetValue,
+ U = T
+> {
+ /** The widget configuration from LiteGraph */
+ widget: SimplifiedWidget
+ /** The current value from parent component */
+ modelValue: T
+ /** Default value if modelValue is null/undefined */
+ defaultValue: T
+ /** Emit function from component setup */
+ emit: (event: 'update:modelValue', value: T) => void
+ /** Optional value transformer before sending to LiteGraph */
+ transform?: (value: U) => T
+}
+
+export interface UseWidgetValueReturn<
+ T extends WidgetValue = WidgetValue,
+ U = T
+> {
+ /** Local value for immediate UI updates */
+ localValue: Ref
+ /** Handler for user interactions */
+ onChange: (newValue: U) => void
+}
+
+/**
+ * Manages widget value synchronization with LiteGraph
+ *
+ * @example
+ * ```vue
+ * const { localValue, onChange } = useWidgetValue({
+ * widget: props.widget,
+ * modelValue: props.modelValue,
+ * defaultValue: ''
+ * })
+ * ```
+ */
+export function useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue,
+ emit,
+ transform
+}: UseWidgetValueOptions): UseWidgetValueReturn {
+ // Local value for immediate UI updates
+ const localValue = ref(modelValue ?? defaultValue)
+
+ // Handle user changes
+ const onChange = (newValue: U) => {
+ // Handle different PrimeVue component signatures
+ let processedValue: T
+ if (transform) {
+ processedValue = transform(newValue)
+ } else {
+ // Ensure type safety - only cast when types are compatible
+ if (
+ typeof newValue === typeof defaultValue ||
+ newValue === null ||
+ newValue === undefined
+ ) {
+ processedValue = (newValue ?? defaultValue) as T
+ } else {
+ console.warn(
+ `useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
+ )
+ processedValue = defaultValue
+ }
+ }
+
+ // 1. Update local state for immediate UI feedback
+ localValue.value = processedValue
+
+ // 2. Emit to parent component
+ emit('update:modelValue', processedValue)
+ }
+
+ // Watch for external updates from LiteGraph
+ watch(
+ () => modelValue,
+ (newValue) => {
+ localValue.value = newValue ?? defaultValue
+ }
+ )
+
+ return {
+ localValue: localValue as Ref,
+ onChange
+ }
+}
+
+/**
+ * Type-specific helper for string widgets
+ */
+export function useStringWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: string,
+ emit: (event: 'update:modelValue', value: string) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: '',
+ emit,
+ transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
+ })
+}
+
+/**
+ * Type-specific helper for number widgets
+ */
+export function useNumberWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: number,
+ emit: (event: 'update:modelValue', value: number) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: 0,
+ emit,
+ transform: (value: number | number[]) => {
+ // Handle PrimeVue Slider which can emit number | number[]
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value[0] ?? 0 : 0
+ }
+ return Number(value) || 0
+ }
+ })
+}
+
+/**
+ * Type-specific helper for boolean widgets
+ */
+export function useBooleanWidgetValue(
+ widget: SimplifiedWidget,
+ modelValue: boolean,
+ emit: (event: 'update:modelValue', value: boolean) => void
+) {
+ return useWidgetValue({
+ widget,
+ modelValue,
+ defaultValue: false,
+ emit,
+ transform: (value: boolean) => Boolean(value)
+ })
+}
diff --git a/src/composables/node/useNodeCanvasImagePreview.ts b/src/composables/node/useNodeCanvasImagePreview.ts
index 98d49b485..008119407 100644
--- a/src/composables/node/useNodeCanvasImagePreview.ts
+++ b/src/composables/node/useNodeCanvasImagePreview.ts
@@ -1,5 +1,5 @@
-import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
diff --git a/src/composables/node/useNodeChatHistory.ts b/src/composables/node/useNodeChatHistory.ts
index 8fbe78895..a1fa3ad3a 100644
--- a/src/composables/node/useNodeChatHistory.ts
+++ b/src/composables/node/useNodeChatHistory.ts
@@ -1,6 +1,6 @@
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
-import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { useChatHistoryWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget'
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'
diff --git a/src/composables/node/useNodeProgressText.ts b/src/composables/node/useNodeProgressText.ts
index 12e09bd5e..07e7488ea 100644
--- a/src/composables/node/useNodeProgressText.ts
+++ b/src/composables/node/useNodeProgressText.ts
@@ -1,5 +1,5 @@
-import { useTextPreviewWidget } from '@/composables/widgets/useProgressTextWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import { useTextPreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget'
const TEXT_PREVIEW_WIDGET_NAME = '$$node-text-preview'
diff --git a/src/composables/useCoreCommands.ts b/src/composables/useCoreCommands.ts
index 8fe83eb36..61ed889bb 100644
--- a/src/composables/useCoreCommands.ts
+++ b/src/composables/useCoreCommands.ts
@@ -282,6 +282,18 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.setDirty(true, true)
}
},
+ {
+ id: 'Experimental.ToggleVueNodes',
+ label: () =>
+ `Experimental: ${
+ useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
+ } Vue Nodes`,
+ function: async () => {
+ const settingStore = useSettingStore()
+ const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false
+ await settingStore.set('Comfy.VueNodes.Enabled', !current)
+ }
+ },
{
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',
diff --git a/src/composables/useFeatureFlags.ts b/src/composables/useFeatureFlags.ts
index a578eb8bf..9a0bcd03d 100644
--- a/src/composables/useFeatureFlags.ts
+++ b/src/composables/useFeatureFlags.ts
@@ -12,10 +12,9 @@ export enum ServerFeatureFlag {
}
/**
- * Composable for reactive access to feature flags
+ * Composable for reactive access to server-side feature flags
*/
export function useFeatureFlags() {
- // Create reactive state that tracks server feature flags
const flags = reactive({
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
@@ -28,10 +27,8 @@ export function useFeatureFlags() {
}
})
- // Create a reactive computed for any feature flag
- const featureFlag = (featurePath: string, defaultValue?: T) => {
- return computed(() => api.getServerFeature(featurePath, defaultValue))
- }
+ const featureFlag = (featurePath: string, defaultValue?: T) =>
+ computed(() => api.getServerFeature(featurePath, defaultValue))
return {
flags: readonly(flags),
diff --git a/src/composables/useTemplateFiltering.ts b/src/composables/useTemplateFiltering.ts
index 14e5c6768..6bf0b53a2 100644
--- a/src/composables/useTemplateFiltering.ts
+++ b/src/composables/useTemplateFiltering.ts
@@ -2,7 +2,8 @@ import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
-export interface TemplateFilterOptions {
+// @ts-expect-error unused (To be used later?)
+interface TemplateFilterOptions {
searchQuery?: string
}
diff --git a/src/composables/useVueFeatureFlags.ts b/src/composables/useVueFeatureFlags.ts
new file mode 100644
index 000000000..8816555d1
--- /dev/null
+++ b/src/composables/useVueFeatureFlags.ts
@@ -0,0 +1,38 @@
+/**
+ * Vue-related feature flags composable
+ * Manages local settings-driven flags and LiteGraph integration
+ */
+import { computed, watch } from 'vue'
+
+import { useSettingStore } from '@/stores/settingStore'
+
+import { LiteGraph } from '../lib/litegraph/src/litegraph'
+
+export const useVueFeatureFlags = () => {
+ const settingStore = useSettingStore()
+
+ const isVueNodesEnabled = computed(() => {
+ try {
+ return settingStore.get('Comfy.VueNodes.Enabled') ?? false
+ } catch {
+ return false
+ }
+ })
+
+ // Whether Vue nodes should render
+ const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
+
+ // Sync the Vue nodes flag with LiteGraph global settings
+ const syncVueNodesFlag = () => {
+ LiteGraph.vueNodesMode = isVueNodesEnabled.value
+ }
+
+ // Watch for changes and update LiteGraph immediately
+ watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
+
+ return {
+ isVueNodesEnabled,
+ shouldRenderVueNodes,
+ syncVueNodesFlag
+ }
+}
diff --git a/src/constants/coreSettings.ts b/src/constants/coreSettings.ts
index 45093da5d..9b6a2a02c 100644
--- a/src/constants/coreSettings.ts
+++ b/src/constants/coreSettings.ts
@@ -952,5 +952,19 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
+ },
+
+ /**
+ * Vue Node System Settings
+ */
+ {
+ id: 'Comfy.VueNodes.Enabled',
+ name: 'Enable Vue node rendering (hidden)',
+ type: 'hidden',
+ tooltip:
+ 'Render nodes as Vue components instead of canvas. Hidden; toggle via Experimental keybinding.',
+ defaultValue: false,
+ experimental: true,
+ versionAdded: '1.27.1'
}
]
diff --git a/src/constants/serverConfig.ts b/src/constants/serverConfig.ts
index 2f04e4cf3..45991f737 100644
--- a/src/constants/serverConfig.ts
+++ b/src/constants/serverConfig.ts
@@ -1,5 +1,4 @@
import {
- AutoLaunch,
CrossAttentionMethod,
CudaMalloc,
FloatingPointPrecision,
@@ -20,32 +19,6 @@ export interface ServerConfig extends FormItem {
getValue?: (value: T) => Record
}
-export const WEB_ONLY_CONFIG_ITEMS: ServerConfig[] = [
- // Launch behavior
- {
- id: 'auto-launch',
- name: 'Automatically opens in the browser on startup',
- category: ['Launch'],
- type: 'combo',
- options: Object.values(AutoLaunch),
- defaultValue: AutoLaunch.Auto,
- getValue: (value: AutoLaunch) => {
- switch (value) {
- case AutoLaunch.Auto:
- return {}
- case AutoLaunch.Enable:
- return {
- ['auto-launch']: true
- }
- case AutoLaunch.Disable:
- return {
- ['disable-auto-launch']: true
- }
- }
- }
- }
-]
-
export const SERVER_CONFIG_ITEMS: ServerConfig[] = [
// Network settings
{
diff --git a/src/constants/slotColors.ts b/src/constants/slotColors.ts
new file mode 100644
index 000000000..797bd94f5
--- /dev/null
+++ b/src/constants/slotColors.ts
@@ -0,0 +1,30 @@
+/**
+ * Default colors for node slot types
+ * Mirrors LiteGraph's slot_default_color_by_type
+ */
+export const SLOT_TYPE_COLORS: Record = {
+ number: '#AAD',
+ string: '#DCA',
+ boolean: '#DAA',
+ vec2: '#ADA',
+ vec3: '#ADA',
+ vec4: '#ADA',
+ color: '#DDA',
+ image: '#353',
+ latent: '#858',
+ conditioning: '#FFA',
+ control_net: '#F8F',
+ clip: '#FFD',
+ vae: '#F82',
+ model: '#B98',
+ '*': '#AAA' // Default color
+}
+
+/**
+ * Get the color for a slot type
+ */
+export function getSlotColor(type?: string | number | null): string {
+ if (!type) return SLOT_TYPE_COLORS['*']
+ const typeStr = String(type).toLowerCase()
+ return SLOT_TYPE_COLORS[typeStr] || SLOT_TYPE_COLORS['*']
+}
diff --git a/src/extensions/core/groupNodeManage.ts b/src/extensions/core/groupNodeManage.ts
index 15b9c95bc..7cc7fb220 100644
--- a/src/extensions/core/groupNodeManage.ts
+++ b/src/extensions/core/groupNodeManage.ts
@@ -121,7 +121,7 @@ export class ManageGroupDialog extends ComfyDialog {
getGroupData() {
this.groupNodeType = LiteGraph.registered_node_types[
`${PREFIX}${SEPARATOR}` + this.selectedGroup
- ] as LGraphNodeConstructor
+ ] as unknown as LGraphNodeConstructor
this.groupNodeDef = this.groupNodeType.nodeData
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
}
diff --git a/src/extensions/core/load3d/interfaces.ts b/src/extensions/core/load3d/interfaces.ts
index 51247a8a3..fa05ce2b0 100644
--- a/src/extensions/core/load3d/interfaces.ts
+++ b/src/extensions/core/load3d/interfaces.ts
@@ -185,12 +185,3 @@ export interface LoaderManagerInterface {
dispose(): void
loadModel(url: string, originalFileName?: string): Promise
}
-
-export interface RecordingManagerInterface extends BaseManager {
- startRecording(): Promise
- stopRecording(): void
- hasRecording(): boolean
- getRecordingDuration(): number
- exportRecording(filename?: string): void
- clearRecording(): void
-}
diff --git a/src/extensions/core/widgetInputs.ts b/src/extensions/core/widgetInputs.ts
index bc0725627..46954430f 100644
--- a/src/extensions/core/widgetInputs.ts
+++ b/src/extensions/core/widgetInputs.ts
@@ -422,6 +422,7 @@ function getConfig(this: LGraphNode, widgetName: string) {
* @param node The node to convert the widget to an input slot for.
* @param widget The widget to convert to an input slot.
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
+ * @knipIgnoreUnusedButUsedByCustomNodes
*/
export function convertToInput(
node: LGraphNode,
diff --git a/src/lib/litegraph/CLAUDE.md b/src/lib/litegraph/CLAUDE.md
index d0326b505..68f8bea95 100644
--- a/src/lib/litegraph/CLAUDE.md
+++ b/src/lib/litegraph/CLAUDE.md
@@ -22,7 +22,7 @@
# Workflow
-- Be sure to typecheck when you’re done making a series of code changes
+- Be sure to typecheck when you're done making a series of code changes
- Prefer running single tests, and not the whole test suite, for performance
# Testing Guidelines
diff --git a/src/lib/litegraph/src/LGraph.ts b/src/lib/litegraph/src/LGraph.ts
index 94eee60de..cdd387d89 100644
--- a/src/lib/litegraph/src/LGraph.ts
+++ b/src/lib/litegraph/src/LGraph.ts
@@ -6,6 +6,8 @@ import {
} from '@/lib/litegraph/src/constants'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { LayoutSource } from '@/renderer/core/layout/types'
import type { DragAndScaleState } from './DragAndScale'
import { LGraphCanvas } from './LGraphCanvas'
@@ -1336,6 +1338,7 @@ export class LGraph
* @returns The newly created reroute - typically ignored.
*/
createReroute(pos: Point, before: LinkSegment): Reroute {
+ const layoutMutations = useLayoutMutations()
const rerouteId = ++this.state.lastRerouteId
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
@@ -1349,6 +1352,16 @@ export class LGraph
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
+
+ // Register reroute in Layout Store for spatial tracking
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.createReroute(
+ rerouteId,
+ { x: pos[0], y: pos[1] },
+ before.parentId,
+ Array.from(linkIds)
+ )
+
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
@@ -1379,6 +1392,7 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
+ const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1422,6 +1436,11 @@ export class LGraph
}
reroutes.delete(id)
+
+ // Delete reroute from Layout Store
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.deleteReroute(id)
+
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
this.setDirtyCanvas(false, true)
@@ -2105,6 +2124,7 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
+ const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2245,6 +2265,9 @@ export class LGraph
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
+ // Clean up layout store
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.deleteReroute(reroute.id)
}
}
diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts
index a32b8c354..9dacd426e 100644
--- a/src/lib/litegraph/src/LGraphCanvas.ts
+++ b/src/lib/litegraph/src/LGraphCanvas.ts
@@ -2,6 +2,11 @@ import { toString } from 'es-toolkit/compat'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
+import {
+ type LinkRenderContext,
+ LitegraphLinkAdapter
+} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -51,7 +56,6 @@ import {
containsRect,
createBounds,
distance,
- findPointOnCurve,
isInRect,
isInRectangle,
isPointInRect,
@@ -235,9 +239,6 @@ export class LGraphCanvas
static #tmp_area = new Float32Array(4)
static #margin_area = new Float32Array(4)
static #link_bounding = new Float32Array(4)
- static #lTempA: Point = new Float32Array(2)
- static #lTempB: Point = new Float32Array(2)
- static #lTempC: Point = new Float32Array(2)
static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -679,6 +680,9 @@ export class LGraphCanvas
/** Set on keydown, keyup. @todo */
#shiftDown: boolean = false
+ /** Link rendering adapter for litegraph-to-canvas integration */
+ linkRenderer: LitegraphLinkAdapter | null = null
+
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
dragZoomEnabled: boolean = false
/** The start position of the drag zoom. */
@@ -748,6 +752,13 @@ export class LGraphCanvas
}
}
+ // Initialize link renderer if graph is available
+ if (graph) {
+ this.linkRenderer = new LitegraphLinkAdapter(graph)
+ // Disable layout writes during render
+ this.linkRenderer.enableLayoutStoreWrites = false
+ }
+
this.linkConnector.events.addEventListener('link-created', () =>
this.#dirty()
)
@@ -1843,6 +1854,11 @@ export class LGraphCanvas
this.clear()
newGraph.attachCanvas(this)
+ // Re-initialize link renderer with new graph
+ this.linkRenderer = new LitegraphLinkAdapter(newGraph)
+ // Disable layout writes during render
+ this.linkRenderer.enableLayoutStoreWrites = false
+
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()
}
@@ -2236,11 +2252,22 @@ export class LGraphCanvas
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
- const reroute = graph.getRerouteOnPos(
- e.canvasX,
- e.canvasY,
- this.#visibleReroutes
- )
+ // Try layout store first, fallback to old method
+ const rerouteLayout = layoutStore.queryRerouteAtPoint({
+ x: e.canvasX,
+ y: e.canvasY
+ })
+
+ let reroute: Reroute | undefined
+ if (rerouteLayout) {
+ reroute = graph.getReroute(rerouteLayout.id)
+ } else {
+ reroute = graph.getRerouteOnPos(
+ e.canvasX,
+ e.canvasY,
+ this.#visibleReroutes
+ )
+ }
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
@@ -2406,8 +2433,18 @@ export class LGraphCanvas
// Reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
+ // Try layout store first for hit detection
+ const rerouteLayout = layoutStore.queryRerouteAtPoint({ x, y })
+ let foundReroute: Reroute | undefined
+
+ if (rerouteLayout) {
+ foundReroute = graph.getReroute(rerouteLayout.id)
+ }
+
+ // Fallback to checking visible reroutes directly
for (const reroute of this.#visibleReroutes) {
- const overReroute = reroute.containsPoint([x, y])
+ const overReroute =
+ foundReroute === reroute || reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue
if (overReroute) {
@@ -2441,16 +2478,32 @@ export class LGraphCanvas
this.ctx.lineWidth = this.connections_width + 7
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
+ // Try layout store for segment hit testing first (more precise)
+ const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
+
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
+ // Check if this link segment was hit
+ let isLinkHit =
+ hitSegment &&
+ linkSegment.id ===
+ (linkSegment instanceof Reroute
+ ? hitSegment.rerouteId
+ : hitSegment.linkId)
+
+ if (!isLinkHit && linkSegment.path) {
+ // Fallback to direct path hit testing if not found in layout store
+ isLinkHit = this.ctx.isPointInStroke(
+ linkSegment.path,
+ x * dpi,
+ y * dpi
+ )
+ }
+
// If we shift click on a link then start a link from that input
- if (
- (e.shiftKey || e.altKey) &&
- linkSegment.path &&
- this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
- ) {
+ if ((e.shiftKey || e.altKey) && isLinkHit) {
this.ctx.lineWidth = lineWidth
if (e.shiftKey && !e.altKey) {
@@ -2465,7 +2518,10 @@ export class LGraphCanvas
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
return
}
- } else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) {
+ } else if (
+ this.linkMarkerShape !== LinkMarkerShape.None &&
+ isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)
+ ) {
this.ctx.lineWidth = lineWidth
pointer.onClick = () => this.showLinkMenu(linkSegment, e)
@@ -3178,8 +3234,27 @@ export class LGraphCanvas
// For input/output hovering
// to store the output of isOverNodeInput
const pos: Point = [0, 0]
- const inputId = isOverNodeInput(node, x, y, pos)
- const outputId = isOverNodeOutput(node, x, y, pos)
+
+ // Try to use layout store for hit testing first, fallback to old method
+ let inputId: number = -1
+ let outputId: number = -1
+
+ const slotLayout = layoutStore.querySlotAtPoint({ x, y })
+ if (slotLayout && slotLayout.nodeId === String(node.id)) {
+ if (slotLayout.type === 'input') {
+ inputId = slotLayout.index
+ pos[0] = slotLayout.position.x
+ pos[1] = slotLayout.position.y
+ } else {
+ outputId = slotLayout.index
+ pos[0] = slotLayout.position.x
+ pos[1] = slotLayout.position.y
+ }
+ } else {
+ // Fallback to old method
+ inputId = isOverNodeInput(node, x, y, pos)
+ outputId = isOverNodeOutput(node, x, y, pos)
+ }
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
if (!node.mouseOver) {
@@ -4640,18 +4715,28 @@ export class LGraphCanvas
: LiteGraph.CONNECTING_LINK_COLOR
// the connection being dragged by the mouse
- this.renderLink(
- ctx,
- pos,
- highlightPos,
- null,
- false,
- null,
- colour,
- fromDirection,
- dragDirection
- )
+ if (this.linkRenderer) {
+ this.linkRenderer.renderLinkDirect(
+ ctx,
+ pos,
+ highlightPos,
+ null,
+ false,
+ null,
+ colour,
+ fromDirection,
+ dragDirection,
+ {
+ ...this.buildLinkRenderContext(),
+ linkMarkerShape: LinkMarkerShape.None
+ },
+ {
+ disabled: false
+ }
+ )
+ }
+ ctx.fillStyle = colour
ctx.beginPath()
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
@@ -4724,6 +4809,11 @@ export class LGraphCanvas
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
+ // Skip hit detection if center markers are disabled
+ if (this.linkMarkerShape === LinkMarkerShape.None) {
+ return undefined
+ }
+
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
@@ -5049,6 +5139,19 @@ export class LGraphCanvas
drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void {
this.current_node = node
+ // When Vue nodes mode is enabled, LiteGraph should not draw node chrome or widgets.
+ // We still need to keep slot metrics and layout in sync for hit-testing and links.
+ // Interaction system changes coming later, chances are vue nodes mode will be mostly broken on land
+ if (LiteGraph.vueNodesMode) {
+ // Prepare concrete slots and compute layout measures without rendering visuals.
+ node._setConcreteSlots()
+ if (!node.collapsed) {
+ node.arrange()
+ }
+ // Skip all node body/widget/title rendering. Vue overlay handles visuals.
+ return
+ }
+
const color = node.renderingColor
const bgcolor = node.renderingBgColor
@@ -5762,6 +5865,34 @@ export class LGraphCanvas
}
}
+ /**
+ * Build LinkRenderContext from canvas properties
+ * Helper method for using LitegraphLinkAdapter
+ */
+ private buildLinkRenderContext(): LinkRenderContext {
+ return {
+ // Canvas settings
+ renderMode: this.links_render_mode,
+ connectionWidth: this.connections_width,
+ renderBorder: this.render_connections_border,
+ lowQuality: this.low_quality,
+ highQualityRender: this.highquality_render,
+ scale: this.ds.scale,
+ linkMarkerShape: this.linkMarkerShape,
+ renderConnectionArrows: this.render_connection_arrows,
+
+ // State
+ highlightedLinks: new Set(Object.keys(this.highlighted_links)),
+
+ // Colors
+ defaultLinkColor: this.default_link_color,
+ linkTypeColors: LGraphCanvas.link_type_colors,
+
+ // Pattern for disabled links
+ disabledPattern: this._pattern
+ }
+ }
+
/**
* draws a link between two points
* @param ctx Canvas 2D rendering context
@@ -5803,333 +5934,27 @@ export class LGraphCanvas
disabled?: boolean
} = {}
): void {
- const linkColour =
- link != null && this.highlighted_links[link.id]
- ? '#FFF'
- : color ||
- link?.color ||
- (link?.type != null && LGraphCanvas.link_type_colors[link.type]) ||
- this.default_link_color
- const startDir = start_dir || LinkDirection.RIGHT
- const endDir = end_dir || LinkDirection.LEFT
-
- const dist =
- this.links_render_mode == LinkRenderType.SPLINE_LINK &&
- (!endControl || !startControl)
- ? distance(a, b)
- : 0
-
- // TODO: Subline code below was inserted in the wrong place - should be before this statement
- if (this.render_connections_border && !this.low_quality) {
- ctx.lineWidth = this.connections_width + 4
- }
- ctx.lineJoin = 'round'
- num_sublines ||= 1
- if (num_sublines > 1) ctx.lineWidth = 0.5
-
- // begin line shape
- const path = new Path2D()
-
- /** The link or reroute we're currently rendering */
- const linkSegment = reroute ?? link
- if (linkSegment) linkSegment.path = path
-
- const innerA = LGraphCanvas.#lTempA
- const innerB = LGraphCanvas.#lTempB
-
- /** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
- const pos: Point = linkSegment?._pos ?? [0, 0]
-
- for (let i = 0; i < num_sublines; i++) {
- const offsety = (i - (num_sublines - 1) * 0.5) * 5
- innerA[0] = a[0]
- innerA[1] = a[1]
- innerB[0] = b[0]
- innerB[1] = b[1]
-
- if (this.links_render_mode == LinkRenderType.SPLINE_LINK) {
- if (endControl) {
- innerB[0] = b[0] + endControl[0]
- innerB[1] = b[1] + endControl[1]
- } else {
- this.#addSplineOffset(innerB, endDir, dist)
+ if (this.linkRenderer) {
+ const context = this.buildLinkRenderContext()
+ this.linkRenderer.renderLinkDirect(
+ ctx,
+ a,
+ b,
+ link,
+ skip_border,
+ flow,
+ color,
+ start_dir,
+ end_dir,
+ context,
+ {
+ reroute,
+ startControl,
+ endControl,
+ num_sublines,
+ disabled
}
- if (startControl) {
- innerA[0] = a[0] + startControl[0]
- innerA[1] = a[1] + startControl[1]
- } else {
- this.#addSplineOffset(innerA, startDir, dist)
- }
- path.moveTo(a[0], a[1] + offsety)
- path.bezierCurveTo(
- innerA[0],
- innerA[1] + offsety,
- innerB[0],
- innerB[1] + offsety,
- b[0],
- b[1] + offsety
- )
-
- // Calculate centre point
- findPointOnCurve(pos, a, b, innerA, innerB, 0.5)
-
- if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
- const justPastCentre = LGraphCanvas.#lTempC
- findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51)
-
- linkSegment._centreAngle = Math.atan2(
- justPastCentre[1] - pos[1],
- justPastCentre[0] - pos[0]
- )
- }
- } else {
- const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10
- switch (startDir) {
- case LinkDirection.LEFT:
- innerA[0] += -l
- break
- case LinkDirection.RIGHT:
- innerA[0] += l
- break
- case LinkDirection.UP:
- innerA[1] += -l
- break
- case LinkDirection.DOWN:
- innerA[1] += l
- break
- }
- switch (endDir) {
- case LinkDirection.LEFT:
- innerB[0] += -l
- break
- case LinkDirection.RIGHT:
- innerB[0] += l
- break
- case LinkDirection.UP:
- innerB[1] += -l
- break
- case LinkDirection.DOWN:
- innerB[1] += l
- break
- }
- if (this.links_render_mode == LinkRenderType.LINEAR_LINK) {
- path.moveTo(a[0], a[1] + offsety)
- path.lineTo(innerA[0], innerA[1] + offsety)
- path.lineTo(innerB[0], innerB[1] + offsety)
- path.lineTo(b[0], b[1] + offsety)
-
- // Calculate centre point
- pos[0] = (innerA[0] + innerB[0]) * 0.5
- pos[1] = (innerA[1] + innerB[1]) * 0.5
-
- if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
- linkSegment._centreAngle = Math.atan2(
- innerB[1] - innerA[1],
- innerB[0] - innerA[0]
- )
- }
- } else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) {
- const midX = (innerA[0] + innerB[0]) * 0.5
-
- path.moveTo(a[0], a[1])
- path.lineTo(innerA[0], innerA[1])
- path.lineTo(midX, innerA[1])
- path.lineTo(midX, innerB[1])
- path.lineTo(innerB[0], innerB[1])
- path.lineTo(b[0], b[1])
-
- // Calculate centre point
- pos[0] = midX
- pos[1] = (innerA[1] + innerB[1]) * 0.5
-
- if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
- const diff = innerB[1] - innerA[1]
- if (Math.abs(diff) < 4) linkSegment._centreAngle = 0
- else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5
- else linkSegment._centreAngle = -(Math.PI * 0.5)
- }
- } else {
- return
- }
- }
- }
-
- // rendering the outline of the connection can be a little bit slow
- if (this.render_connections_border && !this.low_quality && !skip_border) {
- ctx.strokeStyle = 'rgba(0,0,0,0.5)'
- ctx.stroke(path)
- }
-
- ctx.lineWidth = this.connections_width
- ctx.fillStyle = ctx.strokeStyle = linkColour
- ctx.stroke(path)
-
- // render arrow in the middle
- if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) {
- // render arrow
- if (this.render_connection_arrows) {
- // compute two points in the connection
- const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir)
- const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir)
- const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir)
- const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir)
-
- // compute the angle between them so the arrow points in the right direction
- let angleA = 0
- let angleB = 0
- if (this.render_curved_connections) {
- angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1])
- angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1])
- } else {
- angleB = angleA = b[1] > a[1] ? 0 : Math.PI
- }
-
- // render arrow
- const transform = ctx.getTransform()
- ctx.translate(posA[0], posA[1])
- ctx.rotate(angleA)
- ctx.beginPath()
- ctx.moveTo(-5, -3)
- ctx.lineTo(0, +7)
- ctx.lineTo(+5, -3)
- ctx.fill()
- ctx.setTransform(transform)
-
- ctx.translate(posC[0], posC[1])
- ctx.rotate(angleB)
- ctx.beginPath()
- ctx.moveTo(-5, -3)
- ctx.lineTo(0, +7)
- ctx.lineTo(+5, -3)
- ctx.fill()
- ctx.setTransform(transform)
- }
-
- // Draw link centre marker
- ctx.beginPath()
- if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
- const transform = ctx.getTransform()
- ctx.translate(pos[0], pos[1])
- if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle)
- // The math is off, but it currently looks better in chromium
- ctx.moveTo(-3.2, -5)
- ctx.lineTo(+7, 0)
- ctx.lineTo(-3.2, +5)
- ctx.setTransform(transform)
- } else if (
- this.linkMarkerShape == null ||
- this.linkMarkerShape === LinkMarkerShape.Circle
- ) {
- ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2)
- }
- if (disabled) {
- const { fillStyle, globalAlpha } = ctx
- ctx.fillStyle = this._pattern ?? '#797979'
- ctx.globalAlpha = 0.75
- ctx.fill()
- ctx.globalAlpha = globalAlpha
- ctx.fillStyle = fillStyle
- }
- ctx.fill()
-
- if (LLink._drawDebug) {
- const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx
- ctx.globalAlpha = 1
- ctx.lineWidth = 4
- ctx.fillStyle = 'white'
- ctx.strokeStyle = 'black'
- ctx.font = '16px Arial'
-
- const text = String(linkSegment.id)
- const { width, actualBoundingBoxAscent } = ctx.measureText(text)
- const x = pos[0] - width * 0.5
- const y = pos[1] + actualBoundingBoxAscent * 0.5
- ctx.strokeText(text, x, y)
- ctx.fillText(text, x, y)
-
- ctx.font = font
- ctx.globalAlpha = globalAlpha
- ctx.lineWidth = lineWidth
- ctx.fillStyle = fillStyle
- ctx.strokeStyle = strokeStyle
- }
- }
-
- // render flowing points
- if (flow) {
- ctx.fillStyle = linkColour
- for (let i = 0; i < 5; ++i) {
- const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1
- const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir)
- ctx.beginPath()
- ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI)
- ctx.fill()
- }
- }
- }
-
- /**
- * Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir.
- * @param a Start point
- * @param b End point
- * @param t Time: distance between points (e.g 0.25 is 25% along the line)
- * @param start_dir Spline start direction
- * @param end_dir Spline end direction
- * @returns The point at {@link t} distance along the spline a-b.
- */
- computeConnectionPoint(
- a: ReadOnlyPoint,
- b: ReadOnlyPoint,
- t: number,
- start_dir: LinkDirection,
- end_dir: LinkDirection
- ): Point {
- start_dir ||= LinkDirection.RIGHT
- end_dir ||= LinkDirection.LEFT
-
- const dist = distance(a, b)
- const pa: Point = [a[0], a[1]]
- const pb: Point = [b[0], b[1]]
-
- this.#addSplineOffset(pa, start_dir, dist)
- this.#addSplineOffset(pb, end_dir, dist)
-
- const c1 = (1 - t) * (1 - t) * (1 - t)
- const c2 = 3 * ((1 - t) * (1 - t)) * t
- const c3 = 3 * (1 - t) * (t * t)
- const c4 = t * t * t
-
- const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0]
- const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1]
- return [x, y]
- }
-
- /**
- * Modifies an existing point, adding a single-axis offset.
- * @param point The point to add the offset to
- * @param direction The direction to add the offset in
- * @param dist Distance to offset
- * @param factor Distance is mulitplied by this value. Default: 0.25
- */
- #addSplineOffset(
- point: Point,
- direction: LinkDirection,
- dist: number,
- factor = 0.25
- ): void {
- switch (direction) {
- case LinkDirection.LEFT:
- point[0] += dist * -factor
- break
- case LinkDirection.RIGHT:
- point[0] += dist * factor
- break
- case LinkDirection.UP:
- point[1] += dist * -factor
- break
- case LinkDirection.DOWN:
- point[1] += dist * factor
- break
+ )
}
}
@@ -6336,6 +6161,8 @@ export class LGraphCanvas
: segment.id
if (linkId !== undefined) {
graph.removeLink(linkId)
+ // Clean up layout store
+ layoutStore.deleteLinkLayout(linkId)
}
break
}
@@ -8413,11 +8240,22 @@ export class LGraphCanvas
// Check for reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
- const reroute = this.graph.getRerouteOnPos(
- event.canvasX,
- event.canvasY,
- this.#visibleReroutes
- )
+ // Try layout store first, fallback to old method
+ const rerouteLayout = layoutStore.queryRerouteAtPoint({
+ x: event.canvasX,
+ y: event.canvasY
+ })
+
+ let reroute: Reroute | undefined
+ if (rerouteLayout) {
+ reroute = this.graph.getReroute(rerouteLayout.id)
+ } else {
+ reroute = this.graph.getRerouteOnPos(
+ event.canvasX,
+ event.canvasY,
+ this.#visibleReroutes
+ )
+ }
if (reroute) {
menu_info.unshift(
{
diff --git a/src/lib/litegraph/src/LGraphNode.ts b/src/lib/litegraph/src/LGraphNode.ts
index 607c4e55a..a68f2a82d 100644
--- a/src/lib/litegraph/src/LGraphNode.ts
+++ b/src/lib/litegraph/src/LGraphNode.ts
@@ -1,3 +1,13 @@
+import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
+import {
+ type SlotPositionContext,
+ calculateInputSlotPos,
+ calculateInputSlotPosFromSlot,
+ calculateOutputSlotPos
+} from '@/renderer/core/canvas/litegraph/slotCalculations'
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { LayoutSource } from '@/renderer/core/layout/types'
+
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
@@ -258,6 +268,10 @@ export class LGraphNode
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IBaseWidget[]
+
+ /** Property manager for this node */
+ changeTracker: LGraphNodeProperties
+
/**
* The amount of space available for widgets to grow into.
* @see {@link layoutWidgets}
@@ -729,6 +743,37 @@ export class LGraphNode
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle
}
+
+ // Assign onMouseDown implementation
+ this.onMouseDown = (
+ // @ts-expect-error - CanvasPointerEvent type needs fixing
+ e: CanvasPointerEvent,
+ pos: Point,
+ canvas: LGraphCanvas
+ ): boolean => {
+ // Check for title button clicks (only if not collapsed)
+ if (this.title_buttons?.length && !this.flags.collapsed) {
+ // pos contains the offset from the node's position, so we need to use node-relative coordinates
+ const nodeRelativeX = pos[0]
+ const nodeRelativeY = pos[1]
+
+ for (let i = 0; i < this.title_buttons.length; i++) {
+ const button = this.title_buttons[i]
+ if (
+ button.visible &&
+ button.isPointInside(nodeRelativeX, nodeRelativeY)
+ ) {
+ this.onTitleButtonClick(button, canvas)
+ return true // Prevent default behavior
+ }
+ }
+ }
+
+ return false // Allow default behavior
+ }
+
+ // Initialize property manager with tracked properties
+ this.changeTracker = new LGraphNodeProperties(this)
}
/** Internal callback for subgraph nodes. Do not implement externally. */
@@ -1941,6 +1986,14 @@ export class LGraphNode
move(deltaX: number, deltaY: number): void {
if (this.pinned) return
+ // If Vue nodes mode is enabled, skip LiteGraph's direct position update
+ // The layout store will handle the movement and sync back to LiteGraph
+ if (LiteGraph.vueNodesMode) {
+ // Vue nodes handle their own dragging through the layout store
+ // This prevents the snap-back issue from conflicting position updates
+ return
+ }
+
this.pos[0] += deltaX
this.pos[1] += deltaY
}
@@ -2745,6 +2798,8 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
+ const layoutMutations = useLayoutMutations()
+
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2803,6 +2858,16 @@ export class LGraphNode
// add to graph links list
graph._links.set(link.id, link)
+ // Register link in Layout Store for spatial tracking
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.createLink(
+ link.id,
+ this.id,
+ outputIndex,
+ inputNode.id,
+ inputIndex
+ )
+
// connect in output
output.links ??= []
output.links.push(link.id)
@@ -3204,6 +3269,25 @@ export class LGraphNode
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
+ /**
+ * Get the context needed for slot position calculations
+ * @internal
+ */
+ #getSlotPositionContext(): SlotPositionContext {
+ return {
+ nodeX: this.pos[0],
+ nodeY: this.pos[1],
+ nodeWidth: this.size[0],
+ nodeHeight: this.size[1],
+ collapsed: this.flags.collapsed ?? false,
+ collapsedWidth: this._collapsed_width,
+ slotStartY: this.constructor.slot_start_y,
+ inputs: this.inputs,
+ outputs: this.outputs,
+ widgets: this.widgets
+ }
+ }
+
/**
* Gets the position of an input slot, in graph co-ordinates.
*
@@ -3212,7 +3296,7 @@ export class LGraphNode
* @returns Position of the input slot
*/
getInputPos(slot: number): Point {
- return this.getInputSlotPos(this.inputs[slot])
+ return calculateInputSlotPos(this.#getSlotPositionContext(), slot)
}
/**
@@ -3221,25 +3305,7 @@ export class LGraphNode
* @returns Position of the centre of the input slot in graph co-ordinates.
*/
getInputSlotPos(input: INodeInputSlot): Point {
- const {
- pos: [nodeX, nodeY]
- } = this
-
- if (this.flags.collapsed) {
- const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
- return [nodeX, nodeY - halfTitle]
- }
-
- const { pos } = input
- if (pos) return [nodeX + pos[0], nodeY + pos[1]]
-
- // default vertical slots
- const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
- const nodeOffsetY = this.constructor.slot_start_y || 0
- const slotIndex = this.#defaultVerticalInputs.indexOf(input)
- const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
-
- return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
+ return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
}
/**
@@ -3250,29 +3316,7 @@ export class LGraphNode
* @returns Position of the output slot
*/
getOutputPos(slot: number): Point {
- const {
- pos: [nodeX, nodeY],
- outputs,
- size: [width]
- } = this
-
- if (this.flags.collapsed) {
- const width = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
- const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
- return [nodeX + width, nodeY - halfTitle]
- }
-
- const outputPos = outputs?.[slot]?.pos
- if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
-
- // default vertical slots
- const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
- const nodeOffsetY = this.constructor.slot_start_y || 0
- const slotIndex = this.#defaultVerticalOutputs.indexOf(this.outputs[slot])
- const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
-
- // TODO: Why +1?
- return [nodeX + width + 1 - offsetX, nodeY + slotY + nodeOffsetY]
+ return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
}
/** @inheritdoc */
@@ -3818,12 +3862,33 @@ export class LGraphNode
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
- slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
- slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
- slot.boundingRect[2] = slot.isWidgetInputSlot
- ? BaseWidget.margin
- : LiteGraph.NODE_SLOT_HEIGHT
- slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
+ if (LiteGraph.vueNodesMode) {
+ // Vue-based slot dimensions
+ const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
+
+ if (slot.isWidgetInputSlot) {
+ // Widget slots have a 20x20 clickable area centered at the position
+ slot.boundingRect[0] = pos[0] - 10
+ slot.boundingRect[1] = pos[1] - 10
+ slot.boundingRect[2] = 20
+ slot.boundingRect[3] = 20
+ } else {
+ // Regular slots have a 20x20 clickable area for the connector
+ // but the full slot height for vertical spacing
+ slot.boundingRect[0] = pos[0] - 10
+ slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
+ slot.boundingRect[2] = 20
+ slot.boundingRect[3] = dimensions.SLOT_HEIGHT
+ }
+ } else {
+ // Traditional LiteGraph dimensions
+ slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
+ slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
+ slot.boundingRect[2] = slot.isWidgetInputSlot
+ ? BaseWidget.margin
+ : LiteGraph.NODE_SLOT_HEIGHT
+ slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
+ }
}
#measureSlots(): ReadOnlyRect | null {
@@ -4019,14 +4084,26 @@ export class LGraphNode
}
if (!slotByWidgetName.size) return
- for (const widget of this.widgets) {
- const slot = slotByWidgetName.get(widget.name)
- if (!slot) continue
+ // Only set custom pos if not using Vue positioning
+ // Vue positioning calculates widget slot positions dynamically
+ if (!LiteGraph.vueNodesMode) {
+ for (const widget of this.widgets) {
+ const slot = slotByWidgetName.get(widget.name)
+ if (!slot) continue
- const actualSlot = this.#concreteInputs[slot.index]
- const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
- actualSlot.pos = [offset, widget.y + offset]
- this.#measureSlot(actualSlot, slot.index, true)
+ const actualSlot = this.#concreteInputs[slot.index]
+ const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
+ actualSlot.pos = [offset, widget.y + offset]
+ this.#measureSlot(actualSlot, slot.index, true)
+ }
+ } else {
+ // For Vue positioning, just measure the slots without setting pos
+ for (const widget of this.widgets) {
+ const slot = slotByWidgetName.get(widget.name)
+ if (!slot) continue
+
+ this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
+ }
}
}
diff --git a/src/lib/litegraph/src/LGraphNodeProperties.ts b/src/lib/litegraph/src/LGraphNodeProperties.ts
new file mode 100644
index 000000000..33eafa02f
--- /dev/null
+++ b/src/lib/litegraph/src/LGraphNodeProperties.ts
@@ -0,0 +1,176 @@
+import type { LGraphNode } from './LGraphNode'
+
+/**
+ * Default properties to track
+ */
+const DEFAULT_TRACKED_PROPERTIES: string[] = ['title', 'flags.collapsed']
+
+/**
+ * Manages node properties with optional change tracking and instrumentation.
+ */
+export class LGraphNodeProperties {
+ /** The node this property manager belongs to */
+ node: LGraphNode
+
+ /** Set of property paths that have been instrumented */
+ #instrumentedPaths = new Set()
+
+ constructor(node: LGraphNode) {
+ this.node = node
+
+ this.#setupInstrumentation()
+ }
+
+ /**
+ * Sets up property instrumentation for all tracked properties
+ */
+ #setupInstrumentation(): void {
+ for (const path of DEFAULT_TRACKED_PROPERTIES) {
+ this.#instrumentProperty(path)
+ }
+ }
+
+ /**
+ * Instruments a single property to track changes
+ */
+ #instrumentProperty(path: string): void {
+ const parts = path.split('.')
+
+ if (parts.length > 1) {
+ this.#ensureNestedPath(path)
+ }
+
+ let targetObject: any = this.node
+ let propertyName = parts[0]
+
+ if (parts.length > 1) {
+ for (let i = 0; i < parts.length - 1; i++) {
+ targetObject = targetObject[parts[i]]
+ }
+ propertyName = parts.at(-1)!
+ }
+
+ const hasProperty = Object.prototype.hasOwnProperty.call(
+ targetObject,
+ propertyName
+ )
+ const currentValue = targetObject[propertyName]
+
+ if (!hasProperty) {
+ let value: any = undefined
+
+ Object.defineProperty(targetObject, propertyName, {
+ get: () => value,
+ set: (newValue: any) => {
+ const oldValue = value
+ value = newValue
+ this.#emitPropertyChange(path, oldValue, newValue)
+
+ // Update enumerable: true for non-undefined values, false for undefined
+ const shouldBeEnumerable = newValue !== undefined
+ const currentDescriptor = Object.getOwnPropertyDescriptor(
+ targetObject,
+ propertyName
+ )
+ if (
+ currentDescriptor &&
+ currentDescriptor.enumerable !== shouldBeEnumerable
+ ) {
+ Object.defineProperty(targetObject, propertyName, {
+ ...currentDescriptor,
+ enumerable: shouldBeEnumerable
+ })
+ }
+ },
+ enumerable: false,
+ configurable: true
+ })
+ } else {
+ Object.defineProperty(
+ targetObject,
+ propertyName,
+ this.#createInstrumentedDescriptor(path, currentValue)
+ )
+ }
+
+ this.#instrumentedPaths.add(path)
+ }
+
+ /**
+ * Creates a property descriptor that emits change events
+ */
+ #createInstrumentedDescriptor(
+ propertyPath: string,
+ initialValue: any
+ ): PropertyDescriptor {
+ let value = initialValue
+
+ return {
+ get: () => value,
+ set: (newValue: any) => {
+ const oldValue = value
+ value = newValue
+ this.#emitPropertyChange(propertyPath, oldValue, newValue)
+ },
+ enumerable: true,
+ configurable: true
+ }
+ }
+
+ /**
+ * Emits a property change event if the node is connected to a graph
+ */
+ #emitPropertyChange(
+ propertyPath: string,
+ oldValue: any,
+ newValue: any
+ ): void {
+ if (oldValue !== newValue && this.node.graph) {
+ this.node.graph.trigger('node:property:changed', {
+ nodeId: this.node.id,
+ property: propertyPath,
+ oldValue,
+ newValue
+ })
+ }
+ }
+
+ /**
+ * Ensures parent objects exist for nested properties
+ */
+ #ensureNestedPath(path: string): void {
+ const parts = path.split('.')
+ let current: any = this.node
+
+ // Create all parent objects except the last property
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i]
+ if (!current[part]) {
+ current[part] = {}
+ }
+ current = current[part]
+ }
+ }
+
+ /**
+ * Checks if a property is being tracked
+ */
+ isTracked(path: string): boolean {
+ return this.#instrumentedPaths.has(path)
+ }
+
+ /**
+ * Gets the list of tracked properties
+ */
+ getTrackedProperties(): string[] {
+ return [...DEFAULT_TRACKED_PROPERTIES]
+ }
+
+ /**
+ * Custom toJSON method for JSON.stringify
+ * Returns undefined to exclude from serialization since we only use defaults
+ */
+ toJSON(): any {
+ return undefined
+ }
+}
diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts
index bd36719ed..58ae4e090 100644
--- a/src/lib/litegraph/src/LLink.ts
+++ b/src/lib/litegraph/src/LLink.ts
@@ -2,6 +2,8 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { Reroute, RerouteId } from './Reroute'
@@ -14,13 +16,14 @@ import type {
LinkSegment,
ReadonlyLinkNetwork
} from './interfaces'
-import { Subgraph } from './litegraph'
import type {
Serialisable,
SerialisableLLink,
SubgraphIO
} from './types/serialisation'
+const layoutMutations = useLayoutMutations()
+
export type LinkId = number
export type SerialisedLLinkArray = [
@@ -460,19 +463,15 @@ export class LLink implements LinkSegment, Serialisable {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network.reroutes.delete(reroute.id)
+ // Delete reroute from Layout Store
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.deleteReroute(reroute.id)
}
}
network.links.delete(this.id)
-
- if (this.originIsIoNode && network instanceof Subgraph) {
- const subgraphInput = network.inputs.at(this.origin_slot)
- if (!subgraphInput)
- throw new Error('Invalid link - subgraph input not found')
-
- subgraphInput.events.dispatch('input-disconnected', {
- input: subgraphInput
- })
- }
+ // Delete link from Layout Store
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.deleteLink(this.id)
}
/**
diff --git a/src/lib/litegraph/src/LiteGraphGlobal.ts b/src/lib/litegraph/src/LiteGraphGlobal.ts
index 42ad95877..a7c1140ae 100644
--- a/src/lib/litegraph/src/LiteGraphGlobal.ts
+++ b/src/lib/litegraph/src/LiteGraphGlobal.ts
@@ -24,6 +24,26 @@ import {
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
+/**
+ * Vue node dimensions configuration for the contract between LiteGraph and Vue components.
+ * These values ensure both systems can independently calculate node, slot, and widget positions
+ * to place them in identical locations.
+ *
+ * IMPORTANT: These values must match the actual rendered dimensions of Vue components
+ * for the positioning contract to work correctly.
+ */
+export const COMFY_VUE_NODE_DIMENSIONS = {
+ spacing: {
+ BETWEEN_SLOTS_AND_BODY: 8,
+ BETWEEN_WIDGETS: 8
+ },
+ components: {
+ HEADER_HEIGHT: 34, // 18 header + 16 padding
+ SLOT_HEIGHT: 24,
+ STANDARD_WIDGET_HEIGHT: 30
+ }
+} as const
+
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -75,6 +95,14 @@ export class LiteGraphGlobal {
WIDGET_SECONDARY_TEXT_COLOR = '#999'
WIDGET_DISABLED_TEXT_COLOR = '#666'
+ /**
+ * Vue node dimensions configuration for the contract between LiteGraph and Vue components.
+ * These values ensure both systems can independently calculate node, slot, and widget positions
+ * to place them in identical locations.
+ */
+ // WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
+ COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
+
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'
@@ -330,6 +358,18 @@ export class LiteGraphGlobal {
*/
saveViewportWithGraph: boolean = true
+ /**
+ * Enable Vue nodes mode for rendering and positioning.
+ * When true:
+ * - Nodes will calculate slot positions using Vue component dimensions
+ * - LiteGraph will skip rendering node bodies entirely
+ * - Vue components will handle all node rendering
+ * - LiteGraph continues to render connections, links, and graph background
+ * This should be set by the frontend when the Vue nodes feature is enabled.
+ * @default false
+ */
+ vueNodesMode: boolean = false
+
// TODO: Remove legacy accessors
LGraph = LGraph
LLink = LLink
diff --git a/src/lib/litegraph/src/Reroute.ts b/src/lib/litegraph/src/Reroute.ts
index 886930227..4ac682599 100644
--- a/src/lib/litegraph/src/Reroute.ts
+++ b/src/lib/litegraph/src/Reroute.ts
@@ -1,3 +1,6 @@
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { LayoutSource } from '@/renderer/core/layout/types'
+
import { LGraphBadge } from './LGraphBadge'
import type { LGraphNode, NodeId } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
@@ -15,6 +18,8 @@ import type {
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
+const layoutMutations = useLayoutMutations()
+
export type RerouteId = number
/** The input or output slot that an incomplete reroute link is connected to. */
@@ -407,8 +412,17 @@ export class Reroute
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
+ const previousPos = { x: this.#pos[0], y: this.#pos[1] }
this.#pos[0] += deltaX
this.#pos[1] += deltaY
+
+ // Update Layout Store with new position
+ layoutMutations.setSource(LayoutSource.Canvas)
+ layoutMutations.moveReroute(
+ this.id,
+ { x: this.#pos[0], y: this.#pos[1] },
+ previousPos
+ )
}
/** @inheritdoc */
diff --git a/src/lib/litegraph/src/draw.ts b/src/lib/litegraph/src/draw.ts
index 756c60db1..b335f3f81 100644
--- a/src/lib/litegraph/src/draw.ts
+++ b/src/lib/litegraph/src/draw.ts
@@ -1,7 +1,7 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour, Rect } from './interfaces'
import { LiteGraph } from './litegraph'
-import { LinkDirection, RenderShape, TitleMode } from './types/globalEnums'
+import { RenderShape, TitleMode } from './types/globalEnums'
const ELLIPSIS = '\u2026'
const TWO_DOT_LEADER = '\u2025'
@@ -22,12 +22,7 @@ export enum SlotShape {
}
/** @see LinkDirection */
-export enum SlotDirection {
- Up = LinkDirection.UP,
- Right = LinkDirection.RIGHT,
- Down = LinkDirection.DOWN,
- Left = LinkDirection.LEFT
-}
+export enum SlotDirection {}
export enum LabelPosition {
Left = 'left',
diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts
index 67870b390..0abbf313b 100644
--- a/src/lib/litegraph/src/interfaces.ts
+++ b/src/lib/litegraph/src/interfaces.ts
@@ -5,6 +5,7 @@ import type { ContextMenu } from './ContextMenu'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { LLink, LinkId } from './LLink'
import type { Reroute, RerouteId } from './Reroute'
+import { SubgraphInput } from './subgraph/SubgraphInput'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import type { SubgraphOutputNode } from './subgraph/SubgraphOutputNode'
import type { LinkDirection, RenderShape } from './types/globalEnums'
@@ -277,9 +278,6 @@ export type KeysOfType = Exclude<
undefined
>
-/** A new type that contains only the properties of T that are of type Match */
-export type PickByType = { [P in keyof T]: Extract }
-
/** The names of all (optional) methods and functions in T */
export type MethodNames = KeysOfType any) | undefined>
@@ -471,6 +469,7 @@ export interface DefaultConnectionColors {
export interface ISubgraphInput extends INodeInputSlot {
_listenerController?: AbortController
+ _subgraphSlot: SubgraphInput
}
/**
diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts
index a4987b645..fd1d1a31a 100644
--- a/src/lib/litegraph/src/litegraph.ts
+++ b/src/lib/litegraph/src/litegraph.ts
@@ -2,13 +2,13 @@ import type { ContextMenu } from './ContextMenu'
import type { LGraphNode } from './LGraphNode'
import { LiteGraphGlobal } from './LiteGraphGlobal'
import type { ConnectingLink, Point } from './interfaces'
-import type { IContextMenuOptions, INodeSlot, Size } from './interfaces'
+import type { IContextMenuOptions, Size } from './interfaces'
import { loadPolyfills } from './polyfills'
import type { CanvasEventDetail } from './types/events'
import type { RenderShape, TitleMode } from './types/globalEnums'
// Must remain above LiteGraphGlobal (circular dependency due to abstract factory behaviour in `configure`)
-export { Subgraph } from './subgraph/Subgraph'
+export { Subgraph, type GraphOrSubgraph } from './subgraph/Subgraph'
export const LiteGraph = new LiteGraphGlobal()
@@ -22,8 +22,6 @@ loadPolyfills()
// Definitions by: NateScarlet
/** @deprecated Use {@link Point} instead. */
export type Vector2 = Point
-/** @deprecated Use {@link Rect} instead. */
-export type Vector4 = [number, number, number, number]
export interface IContextMenuItem {
content: string
@@ -46,14 +44,6 @@ export type ContextMenuEventListener = (
node: LGraphNode
) => boolean | void
-export interface LinkReleaseContext {
- node_to?: LGraphNode
- node_from?: LGraphNode
- slot_from: INodeSlot
- type_filter_in?: string
- type_filter_out?: string
-}
-
export interface LinkReleaseContextExtended {
links: ConnectingLink[]
}
@@ -117,7 +107,6 @@ export type {
LinkNetwork,
LinkSegment,
MethodNames,
- PickByType,
Point,
Positionable,
ReadonlyLinkNetwork,
@@ -134,7 +123,8 @@ export {
} from './LGraphBadge'
export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
-export { LGraphNode, type NodeId } from './LGraphNode'
+export { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
+export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
export { type LinkId, LLink } from './LLink'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'
diff --git a/src/lib/litegraph/src/node/NodeSlot.ts b/src/lib/litegraph/src/node/NodeSlot.ts
index 527c1dfac..48f0a443c 100644
--- a/src/lib/litegraph/src/node/NodeSlot.ts
+++ b/src/lib/litegraph/src/node/NodeSlot.ts
@@ -73,7 +73,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
slot: OptionalProps,
node: LGraphNode
) {
- // Workaround: Ensure internal properties are not copied to the slot (_listenerController
+ // @ts-expect-error Workaround: Ensure internal properties are not copied to the slot (_listenerController
// https://github.com/Comfy-Org/litegraph.js/issues/1138
const maybeSubgraphSlot: OptionalProps<
ISubgraphInput,
diff --git a/src/lib/litegraph/src/node/slotUtils.ts b/src/lib/litegraph/src/node/slotUtils.ts
index 3d97ca142..e0eda79c8 100644
--- a/src/lib/litegraph/src/node/slotUtils.ts
+++ b/src/lib/litegraph/src/node/slotUtils.ts
@@ -84,10 +84,6 @@ export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
return 'link' in slot
}
-export function isINodeOutputSlot(slot: INodeSlot): slot is INodeOutputSlot {
- return 'links' in slot
-}
-
/**
* Type guard: Whether this input slot is attached to a widget.
* @param slot The slot to check.
diff --git a/src/lib/litegraph/src/types/events.ts b/src/lib/litegraph/src/types/events.ts
index 912cc89ff..acbb89043 100644
--- a/src/lib/litegraph/src/types/events.ts
+++ b/src/lib/litegraph/src/types/events.ts
@@ -50,9 +50,6 @@ export interface CanvasMouseEvent
Readonly,
LegacyMouseEvent {}
-/** DragEvent with canvasX/Y and deltaX/Y properties */
-export interface CanvasDragEvent extends DragEvent, CanvasPointerExtensions {}
-
export type CanvasEventDetail =
| GenericEventDetail
| GroupDoubleClickEventDetail
diff --git a/src/lib/litegraph/src/types/globalEnums.ts b/src/lib/litegraph/src/types/globalEnums.ts
index 5e2afcadc..c955b8e1e 100644
--- a/src/lib/litegraph/src/types/globalEnums.ts
+++ b/src/lib/litegraph/src/types/globalEnums.ts
@@ -89,9 +89,6 @@ export enum LGraphEventMode {
}
export enum EaseFunction {
- LINEAR = 'linear',
- EASE_IN_QUAD = 'easeInQuad',
- EASE_OUT_QUAD = 'easeOutQuad',
EASE_IN_OUT_QUAD = 'easeInOutQuad'
}
diff --git a/src/lib/litegraph/src/types/serialisation.ts b/src/lib/litegraph/src/types/serialisation.ts
index 46b4ad7cc..4b1bfed90 100644
--- a/src/lib/litegraph/src/types/serialisation.ts
+++ b/src/lib/litegraph/src/types/serialisation.ts
@@ -179,14 +179,6 @@ export interface ISerialisedGroup {
flags?: IGraphGroupFlags
}
-export type TClipboardLink = [
- targetRelativeIndex: number,
- originSlot: number,
- nodeRelativeIndex: number,
- targetSlot: number,
- targetNodeId: NodeId
-]
-
/** Items copied from the canvas */
export interface ClipboardItems {
nodes?: ISerialisedNode[]
@@ -196,12 +188,6 @@ export interface ClipboardItems {
subgraphs?: ExportedSubgraph[]
}
-/** @deprecated */
-export interface IClipboardContents {
- nodes?: ISerialisedNode[]
- links?: TClipboardLink[]
-}
-
export interface SerialisableReroute {
id: RerouteId
parentId?: RerouteId
diff --git a/src/lib/litegraph/src/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts
index e61a8360a..4dde6949b 100644
--- a/src/lib/litegraph/src/types/widgets.ts
+++ b/src/lib/litegraph/src/types/widgets.ts
@@ -65,6 +65,17 @@ export type IWidget =
| ISliderWidget
| IButtonWidget
| IKnobWidget
+ | IFileUploadWidget
+ | IColorWidget
+ | IMarkdownWidget
+ | IImageWidget
+ | ITreeSelectWidget
+ | IMultiSelectWidget
+ | IChartWidget
+ | IGalleriaWidget
+ | IImageCompareWidget
+ | ISelectButtonWidget
+ | ITextareaWidget
export interface IBooleanWidget extends IBaseWidget {
type: 'toggle'
@@ -138,6 +149,81 @@ export interface ICustomWidget extends IBaseWidget {
value: string | object
}
+/** File upload widget for selecting and uploading files */
+export interface IFileUploadWidget extends IBaseWidget {
+ type: 'fileupload'
+ value: string
+ label?: string
+}
+
+/** Color picker widget for selecting colors */
+export interface IColorWidget extends IBaseWidget {
+ type: 'color'
+ value: string
+}
+
+/** Markdown widget for displaying formatted text */
+export interface IMarkdownWidget extends IBaseWidget {
+ type: 'markdown'
+ value: string
+}
+
+/** Image display widget */
+export interface IImageWidget extends IBaseWidget {
+ type: 'image'
+ value: string
+}
+
+/** Tree select widget for hierarchical selection */
+export interface ITreeSelectWidget
+ extends IBaseWidget {
+ type: 'treeselect'
+ value: string | string[]
+}
+
+/** Multi-select widget for selecting multiple options */
+export interface IMultiSelectWidget
+ extends IBaseWidget {
+ type: 'multiselect'
+ value: string[]
+}
+
+/** Chart widget for displaying data visualizations */
+export interface IChartWidget extends IBaseWidget