diff --git a/CLAUDE.md b/CLAUDE.md
index 2ac2ab06e8..68be11a12a 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/utils/vueNodeFixtures.ts b/browser_tests/fixtures/utils/vueNodeFixtures.ts
new file mode 100644
index 0000000000..5c4541b926
--- /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/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 fadb023484..604c23351d 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 350b01a284..1e2a1017cf 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 0000000000..7a8ae5dd2b
--- /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/package.json b/package.json
index c7acbf6451..f7482346c9 100644
--- a/package.json
+++ b/package.json
@@ -124,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",
@@ -140,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 4f0aeb5014..c0f1f7af87 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
@@ -1707,6 +1719,9 @@ packages:
'@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'}
@@ -3290,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'}
@@ -3342,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}
@@ -4540,6 +4563,9 @@ 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'}
@@ -4726,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==}
@@ -6150,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:
@@ -6876,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'}
@@ -8525,6 +8563,8 @@ snapshots:
'@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
@@ -10436,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:
@@ -10485,6 +10529,8 @@ snapshots:
clone@1.0.4: {}
+ clsx@2.1.1: {}
+
code-excerpt@4.0.0:
dependencies:
convert-to-spaces: 2.0.1
@@ -11738,6 +11784,8 @@ snapshots:
isexe@2.0.0: {}
+ isomorphic.js@0.2.5: {}
+
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -11951,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
@@ -13734,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
@@ -14445,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 dcf8e55fe9..ee6e697f07 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 2e7b036b55..2d31123b98 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 16510d3fd4..c6fa18a8d8 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 b8b8baa4f2..467da7e5d1 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 0000000000..acfa172ee3
--- /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 0000000000..266dd0569e
--- /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 0000000000..5192bd6df4
--- /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 0000000000..3e382492b1
--- /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 0000000000..a5079c4537
--- /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 0000000000..997e331f7e
--- /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 0000000000..669cfceaa2
--- /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 0000000000..1cf2fb3530
--- /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 98d49b4855..008119407e 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 8fbe788956..a1fa3ad3aa 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 12e09bd5ed..07e7488eae 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 8fe83eb367..61ed889bbb 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 a578eb8bf1..9a0bcd03d4 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/useVueFeatureFlags.ts b/src/composables/useVueFeatureFlags.ts
new file mode 100644
index 0000000000..8816555d19
--- /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 45093da5d7..9b6a2a02c3 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/slotColors.ts b/src/constants/slotColors.ts
new file mode 100644
index 0000000000..797bd94f51
--- /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 15b9c95bc1..7cc7fb2205 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/lib/litegraph/CLAUDE.md b/src/lib/litegraph/CLAUDE.md
index d0326b505c..68f8bea953 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 94eee60def..cdd387d899 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 a32b8c354e..9dacd426e7 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 (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
+ 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
}
-
- // 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 607c4e55a0..a68f2a82df 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)
+ }
+ } 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
- 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)
+ 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 0000000000..33eafa02fd
--- /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 bd36719edf..58ae4e0902 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 42ad95877d..a7c1140ae4 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 886930227c..4ac6825994 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/interfaces.ts b/src/lib/litegraph/src/interfaces.ts
index 67870b390e..0a34133f9f 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'
@@ -471,6 +472,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 a4987b6459..5be28a4138 100644
--- a/src/lib/litegraph/src/litegraph.ts
+++ b/src/lib/litegraph/src/litegraph.ts
@@ -8,7 +8,7 @@ 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()
@@ -134,7 +134,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 527c1dfac2..48f0a443cb 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/types/widgets.ts b/src/lib/litegraph/src/types/widgets.ts
index e61a8360a0..4dde6949bc 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 {
+ type: 'chart'
+ value: object
+}
+
+/** Gallery widget for displaying multiple images */
+export interface IGalleriaWidget extends IBaseWidget {
+ type: 'galleria'
+ value: string[]
+}
+
+/** Image comparison widget for comparing two images side by side */
+export interface IImageCompareWidget
+ extends IBaseWidget {
+ type: 'imagecompare'
+ value: string[]
+}
+
+/** Select button widget for selecting from a group of buttons */
+export interface ISelectButtonWidget
+ extends IBaseWidget<
+ string,
+ 'selectbutton',
+ RequiredProps, 'values'>
+ > {
+ type: 'selectbutton'
+ value: string
+}
+
+/** Textarea widget for multi-line text input */
+export interface ITextareaWidget extends IBaseWidget {
+ type: 'textarea'
+ value: string
+}
+
/**
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
* Override linkedWidgets[]
diff --git a/src/lib/litegraph/src/widgets/ChartWidget.ts b/src/lib/litegraph/src/widgets/ChartWidget.ts
new file mode 100644
index 0000000000..3dfbb069bb
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/ChartWidget.ts
@@ -0,0 +1,50 @@
+import type { IChartWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for displaying charts and data visualizations
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class ChartWidget
+ extends BaseWidget
+ implements IChartWidget
+{
+ override type = 'chart' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Chart: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/ColorWidget.ts b/src/lib/litegraph/src/widgets/ColorWidget.ts
new file mode 100644
index 0000000000..dfd1e5afb8
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/ColorWidget.ts
@@ -0,0 +1,50 @@
+import type { IColorWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for displaying a color picker
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class ColorWidget
+ extends BaseWidget
+ implements IColorWidget
+{
+ override type = 'color' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Color: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/FileUploadWidget.ts b/src/lib/litegraph/src/widgets/FileUploadWidget.ts
new file mode 100644
index 0000000000..5025017ed4
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/FileUploadWidget.ts
@@ -0,0 +1,50 @@
+import type { IFileUploadWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for handling file uploads
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class FileUploadWidget
+ extends BaseWidget
+ implements IFileUploadWidget
+{
+ override type = 'fileupload' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Fileupload: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/GalleriaWidget.ts b/src/lib/litegraph/src/widgets/GalleriaWidget.ts
new file mode 100644
index 0000000000..963e517d22
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/GalleriaWidget.ts
@@ -0,0 +1,50 @@
+import type { IGalleriaWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for displaying image galleries
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class GalleriaWidget
+ extends BaseWidget
+ implements IGalleriaWidget
+{
+ override type = 'galleria' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Galleria: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/ImageCompareWidget.ts b/src/lib/litegraph/src/widgets/ImageCompareWidget.ts
new file mode 100644
index 0000000000..d24fc5d856
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/ImageCompareWidget.ts
@@ -0,0 +1,50 @@
+import type { IImageCompareWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for comparing two images side by side
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class ImageCompareWidget
+ extends BaseWidget
+ implements IImageCompareWidget
+{
+ override type = 'imagecompare' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'ImageCompare: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/MarkdownWidget.ts b/src/lib/litegraph/src/widgets/MarkdownWidget.ts
new file mode 100644
index 0000000000..aac8f2cc1e
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/MarkdownWidget.ts
@@ -0,0 +1,50 @@
+import type { IMarkdownWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for displaying markdown formatted text
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class MarkdownWidget
+ extends BaseWidget
+ implements IMarkdownWidget
+{
+ override type = 'markdown' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Markdown: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/MultiSelectWidget.ts b/src/lib/litegraph/src/widgets/MultiSelectWidget.ts
new file mode 100644
index 0000000000..8201bd77d0
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/MultiSelectWidget.ts
@@ -0,0 +1,50 @@
+import type { IMultiSelectWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for selecting multiple options
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class MultiSelectWidget
+ extends BaseWidget
+ implements IMultiSelectWidget
+{
+ override type = 'multiselect' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'MultiSelect: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/SelectButtonWidget.ts b/src/lib/litegraph/src/widgets/SelectButtonWidget.ts
new file mode 100644
index 0000000000..65218e12e3
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/SelectButtonWidget.ts
@@ -0,0 +1,50 @@
+import type { ISelectButtonWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for selecting from a group of buttons
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class SelectButtonWidget
+ extends BaseWidget
+ implements ISelectButtonWidget
+{
+ override type = 'selectbutton' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'SelectButton: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/TextareaWidget.ts b/src/lib/litegraph/src/widgets/TextareaWidget.ts
new file mode 100644
index 0000000000..efd14dfe8e
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/TextareaWidget.ts
@@ -0,0 +1,50 @@
+import type { ITextareaWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for multi-line text input
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class TextareaWidget
+ extends BaseWidget
+ implements ITextareaWidget
+{
+ override type = 'textarea' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'Textarea: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/TreeSelectWidget.ts b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts
new file mode 100644
index 0000000000..afe3dfee71
--- /dev/null
+++ b/src/lib/litegraph/src/widgets/TreeSelectWidget.ts
@@ -0,0 +1,50 @@
+import type { ITreeSelectWidget } from '../types/widgets'
+import {
+ BaseWidget,
+ type DrawWidgetOptions,
+ type WidgetEventOptions
+} from './BaseWidget'
+
+/**
+ * Widget for hierarchical tree selection
+ * This is a widget that only has a Vue widgets implementation
+ */
+export class TreeSelectWidget
+ extends BaseWidget
+ implements ITreeSelectWidget
+{
+ override type = 'treeselect' as const
+
+ drawWidget(ctx: CanvasRenderingContext2D, options: DrawWidgetOptions): void {
+ const { width } = options
+ const { y, height } = this
+
+ const { fillStyle, strokeStyle, textAlign, textBaseline, font } = ctx
+
+ ctx.fillStyle = this.background_color
+ ctx.fillRect(15, y, width - 30, height)
+
+ ctx.strokeStyle = this.outline_color
+ ctx.strokeRect(15, y, width - 30, height)
+
+ ctx.fillStyle = this.text_color
+ ctx.font = '11px monospace'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+
+ const text = 'TreeSelect: Vue-only'
+ ctx.fillText(text, width / 2, y + height / 2)
+
+ Object.assign(ctx, {
+ fillStyle,
+ strokeStyle,
+ textAlign,
+ textBaseline,
+ font
+ })
+ }
+
+ onClick(_options: WidgetEventOptions): void {
+ // This is a widget that only has a Vue widgets implementation
+ }
+}
diff --git a/src/lib/litegraph/src/widgets/widgetMap.ts b/src/lib/litegraph/src/widgets/widgetMap.ts
index ffbb7ae694..87b8614e72 100644
--- a/src/lib/litegraph/src/widgets/widgetMap.ts
+++ b/src/lib/litegraph/src/widgets/widgetMap.ts
@@ -17,12 +17,22 @@ import { toClass } from '@/lib/litegraph/src/utils/type'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { ButtonWidget } from './ButtonWidget'
+import { ChartWidget } from './ChartWidget'
+import { ColorWidget } from './ColorWidget'
import { ComboWidget } from './ComboWidget'
+import { FileUploadWidget } from './FileUploadWidget'
+import { GalleriaWidget } from './GalleriaWidget'
+import { ImageCompareWidget } from './ImageCompareWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
+import { MarkdownWidget } from './MarkdownWidget'
+import { MultiSelectWidget } from './MultiSelectWidget'
import { NumberWidget } from './NumberWidget'
+import { SelectButtonWidget } from './SelectButtonWidget'
import { SliderWidget } from './SliderWidget'
import { TextWidget } from './TextWidget'
+import { TextareaWidget } from './TextareaWidget'
+import { TreeSelectWidget } from './TreeSelectWidget'
export type WidgetTypeMap = {
button: ButtonWidget
@@ -34,6 +44,16 @@ export type WidgetTypeMap = {
string: TextWidget
text: TextWidget
custom: LegacyWidget
+ fileupload: FileUploadWidget
+ color: ColorWidget
+ markdown: MarkdownWidget
+ treeselect: TreeSelectWidget
+ multiselect: MultiSelectWidget
+ chart: ChartWidget
+ galleria: GalleriaWidget
+ imagecompare: ImageCompareWidget
+ selectbutton: SelectButtonWidget
+ textarea: TextareaWidget
[key: string]: BaseWidget
}
@@ -82,6 +102,26 @@ export function toConcreteWidget(
return toClass(TextWidget, narrowedWidget, node)
case 'text':
return toClass(TextWidget, narrowedWidget, node)
+ case 'fileupload':
+ return toClass(FileUploadWidget, narrowedWidget, node)
+ case 'color':
+ return toClass(ColorWidget, narrowedWidget, node)
+ case 'markdown':
+ return toClass(MarkdownWidget, narrowedWidget, node)
+ case 'treeselect':
+ return toClass(TreeSelectWidget, narrowedWidget, node)
+ case 'multiselect':
+ return toClass(MultiSelectWidget, narrowedWidget, node)
+ case 'chart':
+ return toClass(ChartWidget, narrowedWidget, node)
+ case 'galleria':
+ return toClass(GalleriaWidget, narrowedWidget, node)
+ case 'imagecompare':
+ return toClass(ImageCompareWidget, narrowedWidget, node)
+ case 'selectbutton':
+ return toClass(SelectButtonWidget, narrowedWidget, node)
+ case 'textarea':
+ return toClass(TextareaWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}
diff --git a/src/lib/litegraph/test/LGraphNodeProperties.test.ts b/src/lib/litegraph/test/LGraphNodeProperties.test.ts
new file mode 100644
index 0000000000..512b43158d
--- /dev/null
+++ b/src/lib/litegraph/test/LGraphNodeProperties.test.ts
@@ -0,0 +1,163 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { LGraphNodeProperties } from '../src/LGraphNodeProperties'
+
+describe('LGraphNodeProperties', () => {
+ let mockNode: any
+ let mockGraph: any
+
+ beforeEach(() => {
+ mockGraph = {
+ trigger: vi.fn()
+ }
+
+ mockNode = {
+ id: 123,
+ title: 'Test Node',
+ flags: {},
+ graph: mockGraph
+ }
+ })
+
+ describe('constructor', () => {
+ it('should initialize with default tracked properties', () => {
+ const propManager = new LGraphNodeProperties(mockNode)
+ const tracked = propManager.getTrackedProperties()
+
+ expect(tracked).toHaveLength(2)
+ expect(tracked).toContain('title')
+ expect(tracked).toContain('flags.collapsed')
+ })
+ })
+
+ describe('property tracking', () => {
+ it('should track changes to existing properties', () => {
+ new LGraphNodeProperties(mockNode)
+
+ mockNode.title = 'New Title'
+
+ expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
+ nodeId: mockNode.id,
+ property: 'title',
+ oldValue: 'Test Node',
+ newValue: 'New Title'
+ })
+ })
+
+ it('should track changes to nested properties', () => {
+ new LGraphNodeProperties(mockNode)
+
+ mockNode.flags.collapsed = true
+
+ expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
+ nodeId: mockNode.id,
+ property: 'flags.collapsed',
+ oldValue: undefined,
+ newValue: true
+ })
+ })
+
+ it("should not emit events when value doesn't change", () => {
+ new LGraphNodeProperties(mockNode)
+
+ mockNode.title = 'Test Node' // Same value as original
+
+ expect(mockGraph.trigger).toHaveBeenCalledTimes(0)
+ })
+
+ it('should not emit events when node has no graph', () => {
+ mockNode.graph = null
+ new LGraphNodeProperties(mockNode)
+
+ // Should not throw
+ expect(() => {
+ mockNode.title = 'New Title'
+ }).not.toThrow()
+ })
+ })
+
+ describe('isTracked', () => {
+ it('should correctly identify tracked properties', () => {
+ const propManager = new LGraphNodeProperties(mockNode)
+
+ expect(propManager.isTracked('title')).toBe(true)
+ expect(propManager.isTracked('flags.collapsed')).toBe(true)
+ expect(propManager.isTracked('untracked')).toBe(false)
+ })
+ })
+
+ describe('serialization behavior', () => {
+ it('should not make non-existent properties enumerable', () => {
+ new LGraphNodeProperties(mockNode)
+
+ // flags.collapsed doesn't exist initially
+ const descriptor = Object.getOwnPropertyDescriptor(
+ mockNode.flags,
+ 'collapsed'
+ )
+ expect(descriptor?.enumerable).toBe(false)
+ })
+
+ it('should make properties enumerable when set to non-default values', () => {
+ new LGraphNodeProperties(mockNode)
+
+ mockNode.flags.collapsed = true
+
+ const descriptor = Object.getOwnPropertyDescriptor(
+ mockNode.flags,
+ 'collapsed'
+ )
+ expect(descriptor?.enumerable).toBe(true)
+ })
+
+ it('should make properties non-enumerable when set back to undefined', () => {
+ new LGraphNodeProperties(mockNode)
+
+ mockNode.flags.collapsed = true
+ mockNode.flags.collapsed = undefined
+
+ const descriptor = Object.getOwnPropertyDescriptor(
+ mockNode.flags,
+ 'collapsed'
+ )
+ expect(descriptor?.enumerable).toBe(false)
+ })
+
+ it('should keep existing properties enumerable', () => {
+ // title exists initially
+ const initialDescriptor = Object.getOwnPropertyDescriptor(
+ mockNode,
+ 'title'
+ )
+ expect(initialDescriptor?.enumerable).toBe(true)
+
+ new LGraphNodeProperties(mockNode)
+
+ const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
+ expect(afterDescriptor?.enumerable).toBe(true)
+ })
+
+ it('should only include non-undefined values in JSON.stringify', () => {
+ new LGraphNodeProperties(mockNode)
+
+ // Initially, flags.collapsed shouldn't appear
+ let json = JSON.parse(JSON.stringify(mockNode))
+ expect(json.flags.collapsed).toBeUndefined()
+
+ // After setting to true, it should appear
+ mockNode.flags.collapsed = true
+ json = JSON.parse(JSON.stringify(mockNode))
+ expect(json.flags.collapsed).toBe(true)
+
+ // After setting to false, it should still appear (false is not undefined)
+ mockNode.flags.collapsed = false
+ json = JSON.parse(JSON.stringify(mockNode))
+ expect(json.flags.collapsed).toBe(false)
+
+ // After setting back to undefined, it should disappear
+ mockNode.flags.collapsed = undefined
+ json = JSON.parse(JSON.stringify(mockNode))
+ expect(json.flags.collapsed).toBeUndefined()
+ })
+ })
+})
diff --git a/src/lib/litegraph/test/__snapshots__/ConfigureGraph.test.ts.snap b/src/lib/litegraph/test/__snapshots__/ConfigureGraph.test.ts.snap
index e5adac4809..80e344d5bc 100644
--- a/src/lib/litegraph/test/__snapshots__/ConfigureGraph.test.ts.snap
+++ b/src/lib/litegraph/test/__snapshots__/ConfigureGraph.test.ts.snap
@@ -326,3 +326,331 @@ LGraph {
"version": 1,
}
`;
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
+LGraph {
+ "_groups": [
+ LGraphGroup {
+ "_bounding": Float32Array [
+ 20,
+ 20,
+ 1,
+ 3,
+ ],
+ "_children": Set {},
+ "_nodes": [],
+ "_pos": Float32Array [
+ 20,
+ 20,
+ ],
+ "_size": Float32Array [
+ 1,
+ 3,
+ ],
+ "color": "#6029aa",
+ "flags": {},
+ "font": undefined,
+ "font_size": 14,
+ "graph": [Circular],
+ "id": 123,
+ "isPointInside": [Function],
+ "selected": undefined,
+ "setDirtyCanvas": [Function],
+ "title": "A group to test with",
+ },
+ ],
+ "_input_nodes": undefined,
+ "_last_trigger_time": undefined,
+ "_links": Map {},
+ "_nodes": [
+ LGraphNode {
+ "_collapsed_width": undefined,
+ "_level": undefined,
+ "_pos": Float32Array [
+ 10,
+ 10,
+ ],
+ "_posSize": Float32Array [
+ 10,
+ 10,
+ 140,
+ 60,
+ ],
+ "_relative_id": undefined,
+ "_shape": undefined,
+ "_size": Float32Array [
+ 140,
+ 60,
+ ],
+ "action_call": undefined,
+ "action_triggered": undefined,
+ "badgePosition": "top-left",
+ "badges": [],
+ "bgcolor": undefined,
+ "block_delete": undefined,
+ "boxcolor": undefined,
+ "changeTracker": undefined,
+ "clip_area": undefined,
+ "clonable": undefined,
+ "color": undefined,
+ "console": undefined,
+ "exec_version": undefined,
+ "execute_triggered": undefined,
+ "flags": {},
+ "freeWidgetSpace": undefined,
+ "gotFocusAt": undefined,
+ "graph": [Circular],
+ "has_errors": undefined,
+ "id": 1,
+ "ignore_remove": undefined,
+ "inputs": [],
+ "last_serialization": undefined,
+ "locked": undefined,
+ "lostFocusAt": undefined,
+ "mode": 0,
+ "mouseOver": undefined,
+ "order": 0,
+ "outputs": [],
+ "progress": undefined,
+ "properties": {},
+ "properties_info": [],
+ "redraw_on_mouse": undefined,
+ "removable": undefined,
+ "resizable": undefined,
+ "selected": undefined,
+ "serialize_widgets": undefined,
+ "showAdvanced": undefined,
+ "strokeStyles": {
+ "error": [Function],
+ "selected": [Function],
+ },
+ "title": "LGraphNode",
+ "type": "mustBeSet",
+ "widgets": undefined,
+ "widgets_start_y": undefined,
+ "widgets_up": undefined,
+ },
+ ],
+ "_nodes_by_id": {
+ "1": LGraphNode {
+ "_collapsed_width": undefined,
+ "_level": undefined,
+ "_pos": Float32Array [
+ 10,
+ 10,
+ ],
+ "_posSize": Float32Array [
+ 10,
+ 10,
+ 140,
+ 60,
+ ],
+ "_relative_id": undefined,
+ "_shape": undefined,
+ "_size": Float32Array [
+ 140,
+ 60,
+ ],
+ "action_call": undefined,
+ "action_triggered": undefined,
+ "badgePosition": "top-left",
+ "badges": [],
+ "bgcolor": undefined,
+ "block_delete": undefined,
+ "boxcolor": undefined,
+ "changeTracker": undefined,
+ "clip_area": undefined,
+ "clonable": undefined,
+ "color": undefined,
+ "console": undefined,
+ "exec_version": undefined,
+ "execute_triggered": undefined,
+ "flags": {},
+ "freeWidgetSpace": undefined,
+ "gotFocusAt": undefined,
+ "graph": [Circular],
+ "has_errors": undefined,
+ "id": 1,
+ "ignore_remove": undefined,
+ "inputs": [],
+ "last_serialization": undefined,
+ "locked": undefined,
+ "lostFocusAt": undefined,
+ "mode": 0,
+ "mouseOver": undefined,
+ "order": 0,
+ "outputs": [],
+ "progress": undefined,
+ "properties": {},
+ "properties_info": [],
+ "redraw_on_mouse": undefined,
+ "removable": undefined,
+ "resizable": undefined,
+ "selected": undefined,
+ "serialize_widgets": undefined,
+ "showAdvanced": undefined,
+ "strokeStyles": {
+ "error": [Function],
+ "selected": [Function],
+ },
+ "title": "LGraphNode",
+ "type": "mustBeSet",
+ "widgets": undefined,
+ "widgets_start_y": undefined,
+ "widgets_up": undefined,
+ },
+ },
+ "_nodes_executable": [],
+ "_nodes_in_order": [
+ LGraphNode {
+ "_collapsed_width": undefined,
+ "_level": undefined,
+ "_pos": Float32Array [
+ 10,
+ 10,
+ ],
+ "_posSize": Float32Array [
+ 10,
+ 10,
+ 140,
+ 60,
+ ],
+ "_relative_id": undefined,
+ "_shape": undefined,
+ "_size": Float32Array [
+ 140,
+ 60,
+ ],
+ "action_call": undefined,
+ "action_triggered": undefined,
+ "badgePosition": "top-left",
+ "badges": [],
+ "bgcolor": undefined,
+ "block_delete": undefined,
+ "boxcolor": undefined,
+ "changeTracker": undefined,
+ "clip_area": undefined,
+ "clonable": undefined,
+ "color": undefined,
+ "console": undefined,
+ "exec_version": undefined,
+ "execute_triggered": undefined,
+ "flags": {},
+ "freeWidgetSpace": undefined,
+ "gotFocusAt": undefined,
+ "graph": [Circular],
+ "has_errors": undefined,
+ "id": 1,
+ "ignore_remove": undefined,
+ "inputs": [],
+ "last_serialization": undefined,
+ "locked": undefined,
+ "lostFocusAt": undefined,
+ "mode": 0,
+ "mouseOver": undefined,
+ "order": 0,
+ "outputs": [],
+ "progress": undefined,
+ "properties": {},
+ "properties_info": [],
+ "redraw_on_mouse": undefined,
+ "removable": undefined,
+ "resizable": undefined,
+ "selected": undefined,
+ "serialize_widgets": undefined,
+ "showAdvanced": undefined,
+ "strokeStyles": {
+ "error": [Function],
+ "selected": [Function],
+ },
+ "title": "LGraphNode",
+ "type": "mustBeSet",
+ "widgets": undefined,
+ "widgets_start_y": undefined,
+ "widgets_up": undefined,
+ },
+ ],
+ "_subgraphs": Map {},
+ "_version": 3,
+ "catch_errors": true,
+ "config": {},
+ "elapsed_time": 0.01,
+ "errors_in_execution": undefined,
+ "events": CustomEventTarget {},
+ "execution_time": undefined,
+ "execution_timer_id": undefined,
+ "extra": {},
+ "filter": undefined,
+ "fixedtime": 0,
+ "fixedtime_lapse": 0.01,
+ "globaltime": 0,
+ "id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
+ "iteration": 0,
+ "last_update_time": 0,
+ "links": Map {},
+ "list_of_graphcanvas": null,
+ "nodes_actioning": [],
+ "nodes_executedAction": [],
+ "nodes_executing": [],
+ "revision": 0,
+ "runningtime": 0,
+ "starttime": 0,
+ "state": {
+ "lastGroupId": 123,
+ "lastLinkId": 0,
+ "lastNodeId": 1,
+ "lastRerouteId": 0,
+ },
+ "status": 1,
+ "vars": {},
+ "version": 1,
+}
+`;
+
+exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
+LGraph {
+ "_groups": [],
+ "_input_nodes": undefined,
+ "_last_trigger_time": undefined,
+ "_links": Map {},
+ "_nodes": [],
+ "_nodes_by_id": {},
+ "_nodes_executable": [],
+ "_nodes_in_order": [],
+ "_subgraphs": Map {},
+ "_version": 0,
+ "catch_errors": true,
+ "config": {},
+ "elapsed_time": 0.01,
+ "errors_in_execution": undefined,
+ "events": CustomEventTarget {},
+ "execution_time": undefined,
+ "execution_timer_id": undefined,
+ "extra": {},
+ "filter": undefined,
+ "fixedtime": 0,
+ "fixedtime_lapse": 0.01,
+ "globaltime": 0,
+ "id": "d175890f-716a-4ece-ba33-1d17a513b7be",
+ "iteration": 0,
+ "last_update_time": 0,
+ "links": Map {},
+ "list_of_graphcanvas": null,
+ "nodes_actioning": [],
+ "nodes_executedAction": [],
+ "nodes_executing": [],
+ "revision": 0,
+ "runningtime": 0,
+ "starttime": 0,
+ "state": {
+ "lastGroupId": 0,
+ "lastLinkId": 0,
+ "lastNodeId": 0,
+ "lastRerouteId": 0,
+ },
+ "status": 1,
+ "vars": {},
+ "version": 1,
+}
+`;
diff --git a/src/lib/litegraph/test/__snapshots__/LGraph.test.ts.snap b/src/lib/litegraph/test/__snapshots__/LGraph.test.ts.snap
index 17253de25c..628c8e3a4c 100644
--- a/src/lib/litegraph/test/__snapshots__/LGraph.test.ts.snap
+++ b/src/lib/litegraph/test/__snapshots__/LGraph.test.ts.snap
@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -133,6 +134,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -205,6 +207,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
diff --git a/src/lib/litegraph/test/__snapshots__/LGraph_constructor.test.ts.snap b/src/lib/litegraph/test/__snapshots__/LGraph_constructor.test.ts.snap
index 92afb55e0e..106f0d221f 100644
--- a/src/lib/litegraph/test/__snapshots__/LGraph_constructor.test.ts.snap
+++ b/src/lib/litegraph/test/__snapshots__/LGraph_constructor.test.ts.snap
@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -131,6 +132,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -201,6 +203,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index 8000d087c9..761a8baced 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -56,6 +56,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
+ "dropYourFileOr": "Drop your file or",
"back": "Back",
"next": "Next",
"install": "Install",
@@ -151,7 +152,12 @@
"noAudioRecorded": "No audio recorded",
"nodesRunning": "nodes running",
"duplicate": "Duplicate",
- "moreWorkflows": "More workflows"
+ "moreWorkflows": "More workflows",
+ "nodeRenderError": "Node Render Error",
+ "nodeContentError": "Node Content Error",
+ "nodeHeaderError": "Node Header Error",
+ "nodeSlotsError": "Node Slots Error",
+ "nodeWidgetsError": "Node Widgets Error"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -1089,14 +1095,27 @@
"Next Opened Workflow": "Next Opened Workflow",
"Previous Opened Workflow": "Previous Opened Workflow",
"Toggle Search Box": "Toggle Search Box",
+ "Bottom Panel": "Bottom Panel",
"Toggle Bottom Panel": "Toggle Bottom Panel",
+ "Show Keybindings Dialog": "Show Keybindings Dialog",
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
+ "Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
+ "Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Toggle Focus Mode": "Toggle Focus Mode",
+ "Focus Mode": "Focus Mode",
+ "Model Library": "Model Library",
+ "Node Library": "Node Library",
+ "Queue Panel": "Queue Panel",
+ "Workflows": "Workflows",
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
- "Toggle Workflows Sidebar": "Toggle Workflows Sidebar"
+ "Toggle Workflows Sidebar": "Toggle Workflows Sidebar",
+ "sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
+ "sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
+ "sideToolbar_queue": "sideToolbar.queue",
+ "sideToolbar_workflows": "sideToolbar.workflows"
},
"desktopMenu": {
"reinstall": "Reinstall",
@@ -1156,7 +1175,8 @@
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
- "3DViewer": "3DViewer"
+ "3DViewer": "3DViewer",
+ "Vue Nodes": "Vue Nodes"
},
"serverConfigItems": {
"listen": {
diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json
index 3f8d206db7..7c7cec3052 100644
--- a/src/locales/en/settings.json
+++ b/src/locales/en/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Enable Vue node rendering",
+ "tooltip": "Render nodes as Vue components instead of canvas elements. Experimental feature."
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Enable Vue widgets",
+ "tooltip": "Render widgets as Vue components within Vue nodes."
+ },
"Comfy_WidgetControlMode": {
"name": "Widget control mode",
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",
diff --git a/src/locales/es/commands.json b/src/locales/es/commands.json
index 9f2ff9e053..7d2fef949b 100644
--- a/src/locales/es/commands.json
+++ b/src/locales/es/commands.json
@@ -285,7 +285,7 @@
"label": "Alternar panel inferior de controles de vista"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
- "label": "Mostrar diálogo de atajos de teclado"
+ "label": "Mostrar diálogo de combinaciones de teclas"
},
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
diff --git a/src/locales/es/main.json b/src/locales/es/main.json
index 773cf5bcaa..31acf70ba6 100644
--- a/src/locales/es/main.json
+++ b/src/locales/es/main.json
@@ -310,6 +310,8 @@
"disabling": "Deshabilitando",
"dismiss": "Descartar",
"download": "Descargar",
+ "dropYourFileOr": "Suelta tu archivo o",
+ "duplicate": "Duplicar",
"edit": "Editar",
"empty": "Vacío",
"enableAll": "Habilitar todo",
@@ -805,6 +807,7 @@
"Show Model Selector (Dev)": "Mostrar selector de modelo (Desarrollo)",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
+ "Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
@@ -814,7 +817,9 @@
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
+ "Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
+ "Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
@@ -822,7 +827,19 @@
"Unload Models and Execution Cache": "Descargar modelos y caché de ejecución",
"Workflow": "Flujo de trabajo",
"Zoom In": "Acercar",
- "Zoom Out": "Alejar"
+ "Zoom Out": "Alejar",
+ "Zoom to fit": "Ajustar al tamaño"
+ },
+ "minimap": {
+ "nodeColors": "Colores de nodos",
+ "renderBypassState": "Mostrar estado de omisión",
+ "renderErrorState": "Mostrar estado de error",
+ "showGroups": "Mostrar marcos/grupos",
+ "showLinks": "Mostrar enlaces",
+ "sideToolbar_modelLibrary": "sideToolbar.bibliotecaDeModelos",
+ "sideToolbar_nodeLibrary": "sideToolbar.bibliotecaDeNodos",
+ "sideToolbar_queue": "sideToolbar.cola",
+ "sideToolbar_workflows": "sideToolbar.flujosDeTrabajo"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
@@ -1138,6 +1155,7 @@
"UV": "UV",
"User": "Usuario",
"Validation": "Validación",
+ "Vue Nodes": "Nodos Vue",
"Window": "Ventana",
"Workflow": "Flujo de Trabajo"
},
@@ -1625,4 +1643,4 @@
"exportWorkflow": "Exportar flujo de trabajo",
"saveWorkflow": "Guardar flujo de trabajo"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/es/settings.json b/src/locales/es/settings.json
index 3444877edd..b70416af77 100644
--- a/src/locales/es/settings.json
+++ b/src/locales/es/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validar flujos de trabajo"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Habilitar renderizado de nodos Vue",
+ "tooltip": "Renderiza los nodos como componentes Vue en lugar de elementos canvas. Función experimental."
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Habilitar widgets de Vue",
+ "tooltip": "Renderiza los widgets como componentes de Vue dentro de los nodos de Vue."
+ },
"Comfy_WidgetControlMode": {
"name": "Modo de control del widget",
"options": {
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index 3e45f7e0fa..f7201f1874 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -310,6 +310,8 @@
"disabling": "Désactivation",
"dismiss": "Fermer",
"download": "Télécharger",
+ "dropYourFileOr": "Déposez votre fichier ou",
+ "duplicate": "Dupliquer",
"edit": "Modifier",
"empty": "Vide",
"enableAll": "Activer tout",
@@ -806,6 +808,8 @@
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Essential Bottom Panel": "Basculer le panneau inférieur essentiel",
+ "Toggle Bottom Panel": "Basculer le panneau inférieur",
+ "Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
@@ -814,6 +818,7 @@
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle View Controls Bottom Panel": "Basculer le panneau inférieur des contrôles d’affichage",
+ "Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
@@ -822,7 +827,20 @@
"Unload Models and Execution Cache": "Décharger les modèles et le cache d'exécution",
"Workflow": "Flux de travail",
"Zoom In": "Zoom avant",
- "Zoom Out": "Zoom arrière"
+ "Zoom Out": "Zoom arrière",
+ "Zoom to fit": "Ajuster à l'écran"
+ },
+ "minimap": {
+ "nodeColors": "Couleurs des nœuds",
+ "renderBypassState": "Afficher l'état de contournement",
+ "renderErrorState": "Afficher l'état d'erreur",
+ "showGroups": "Afficher les cadres/groupes",
+ "showLinks": "Afficher les liens",
+ "Zoom Out": "Zoom arrière",
+ "sideToolbar_modelLibrary": "Bibliothèque de modèles",
+ "sideToolbar_nodeLibrary": "Bibliothèque de nœuds",
+ "sideToolbar_queue": "File d'attente",
+ "sideToolbar_workflows": "Flux de travail"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
@@ -1138,6 +1156,7 @@
"UV": "UV",
"User": "Utilisateur",
"Validation": "Validation",
+ "Vue Nodes": "Nœuds Vue",
"Window": "Fenêtre",
"Workflow": "Flux de Travail"
},
@@ -1625,4 +1644,4 @@
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/fr/settings.json b/src/locales/fr/settings.json
index b551b52671..5031782cae 100644
--- a/src/locales/fr/settings.json
+++ b/src/locales/fr/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Valider les flux de travail"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Activer le rendu des nœuds Vue",
+ "tooltip": "Rendre les nœuds comme composants Vue au lieu d’éléments canvas. Fonctionnalité expérimentale."
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Activer les widgets Vue",
+ "tooltip": "Rendre les widgets comme composants Vue à l'intérieur des nœuds Vue."
+ },
"Comfy_WidgetControlMode": {
"name": "Mode de contrôle du widget",
"options": {
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index a3ee743604..8b91c2c95d 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -310,6 +310,8 @@
"disabling": "無効化",
"dismiss": "閉じる",
"download": "ダウンロード",
+ "dropYourFileOr": "ファイルをドロップするか",
+ "duplicate": "複製",
"edit": "編集",
"empty": "空",
"enableAll": "すべて有効にする",
@@ -807,10 +809,16 @@
"Sign Out": "サインアウト",
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",
"Toggle Logs Bottom Panel": "ログ下部パネルの切り替え",
+ "Toggle Bottom Panel": "下部パネルの切り替え",
+ "Toggle Focus Mode": "フォーカスモードの切り替え",
+ "Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
+ "Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
+ "Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナル下部パネルの切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
+ "Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
@@ -818,7 +826,19 @@
"Unload Models and Execution Cache": "モデルと実行キャッシュのアンロード",
"Workflow": "ワークフロー",
"Zoom In": "ズームイン",
- "Zoom Out": "ズームアウト"
+ "Zoom Out": "ズームアウト",
+ "Zoom to fit": "全体表示にズーム"
+ },
+ "minimap": {
+ "nodeColors": "ノードの色",
+ "renderBypassState": "バイパス状態を表示",
+ "renderErrorState": "エラー状態を表示",
+ "showGroups": "フレーム/グループを表示",
+ "showLinks": "リンクを表示",
+ "sideToolbar_modelLibrary": "モデルライブラリ",
+ "sideToolbar_nodeLibrary": "ノードライブラリ",
+ "sideToolbar_queue": "キュー",
+ "sideToolbar_workflows": "ワークフロー"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
@@ -1134,6 +1154,7 @@
"UV": "UV",
"User": "ユーザー",
"Validation": "検証",
+ "Vue Nodes": "Vueノード",
"Window": "ウィンドウ",
"Workflow": "ワークフロー"
},
@@ -1621,4 +1642,4 @@
"exportWorkflow": "ワークフローをエクスポート",
"saveWorkflow": "ワークフローを保存"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/ja/settings.json b/src/locales/ja/settings.json
index c31c323c8a..beb3ff20f7 100644
--- a/src/locales/ja/settings.json
+++ b/src/locales/ja/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "ワークフローを検証"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Vueノードレンダリングを有効化",
+ "tooltip": "ノードをキャンバス要素の代わりにVueコンポーネントとしてレンダリングします。実験的な機能です。"
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Vueウィジェットを有効化",
+ "tooltip": "ウィジェットをVueノード内のVueコンポーネントとしてレンダリングします。"
+ },
"Comfy_WidgetControlMode": {
"name": "ウィジェット制御モード",
"options": {
diff --git a/src/locales/ko/commands.json b/src/locales/ko/commands.json
index c1b9e1a302..a389d77015 100644
--- a/src/locales/ko/commands.json
+++ b/src/locales/ko/commands.json
@@ -282,7 +282,7 @@
"label": "필수 하단 패널 전환"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
- "label": "보기 컨트롤 하단 패널 전환"
+ "label": "뷰 컨트롤 하단 패널 전환"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "키 바인딩 대화상자 표시"
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 48e48ad96f..dd573dd069 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -310,6 +310,8 @@
"disabling": "비활성화 중",
"dismiss": "닫기",
"download": "다운로드",
+ "dropYourFileOr": "파일을 드롭하거나",
+ "duplicate": "복제",
"edit": "편집",
"empty": "비어 있음",
"enableAll": "모두 활성화",
@@ -805,16 +807,21 @@
"Show Model Selector (Dev)": "모델 선택기 표시 (개발자용)",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
+ "Toggle Essential Bottom Panel": "필수 하단 패널 전환",
"Toggle Bottom Panel": "하단 패널 전환",
"Toggle Focus Mode": "포커스 모드 전환",
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
- "Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
+ "Toggle Queue Sidebar": "대기열 사이드바 전환",
+ "Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
+ "Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
+ "Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
+ "Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
@@ -822,7 +829,19 @@
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Workflow": "워크플로",
"Zoom In": "확대",
- "Zoom Out": "축소"
+ "Zoom Out": "축소",
+ "Zoom to fit": "화면에 맞추기"
+ },
+ "minimap": {
+ "nodeColors": "노드 색상",
+ "renderBypassState": "바이패스 상태 렌더링",
+ "renderErrorState": "에러 상태 렌더링",
+ "showGroups": "프레임/그룹 표시",
+ "showLinks": "링크 표시",
+ "sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
+ "sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
+ "sideToolbar_queue": "sideToolbar.대기열",
+ "sideToolbar_workflows": "sideToolbar.워크플로우"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
@@ -1138,6 +1157,7 @@
"UV": "UV",
"User": "사용자",
"Validation": "검증",
+ "Vue Nodes": "Vue 노드",
"Window": "창",
"Workflow": "워크플로"
},
@@ -1625,4 +1645,4 @@
"exportWorkflow": "워크플로 내보내기",
"saveWorkflow": "워크플로 저장"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/ko/settings.json b/src/locales/ko/settings.json
index 45117dcb61..1a57c78414 100644
--- a/src/locales/ko/settings.json
+++ b/src/locales/ko/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "워크플로 유효성 검사"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Vue 노드 렌더링 활성화",
+ "tooltip": "노드를 캔버스 요소 대신 Vue 컴포넌트로 렌더링합니다. 실험적인 기능입니다."
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Vue 위젯 활성화",
+ "tooltip": "Vue 노드 내에서 위젯을 Vue 컴포넌트로 렌더링합니다."
+ },
"Comfy_WidgetControlMode": {
"name": "위젯 제어 모드",
"options": {
diff --git a/src/locales/ru/commands.json b/src/locales/ru/commands.json
index ef5c89310c..a1e9609dea 100644
--- a/src/locales/ru/commands.json
+++ b/src/locales/ru/commands.json
@@ -282,7 +282,7 @@
"label": "Показать/скрыть основную нижнюю панель"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
- "label": "Показать или скрыть нижнюю панель управления просмотром"
+ "label": "Показать/скрыть нижнюю панель управления просмотром"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Показать диалог клавиш"
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index 6f67c4ba51..99cb252176 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -310,6 +310,8 @@
"disabling": "Отключение",
"dismiss": "Закрыть",
"download": "Скачать",
+ "dropYourFileOr": "Перетащите ваш файл или",
+ "duplicate": "Дублировать",
"edit": "Редактировать",
"empty": "Пусто",
"enableAll": "Включить все",
@@ -807,10 +809,16 @@
"Sign Out": "Выйти",
"Toggle Essential Bottom Panel": "Показать/скрыть нижнюю панель основных элементов",
"Toggle Logs Bottom Panel": "Показать/скрыть нижнюю панель логов",
+ "Toggle Bottom Panel": "Переключить нижнюю панель",
+ "Toggle Focus Mode": "Переключить режим фокуса",
+ "Toggle Model Library Sidebar": "Показать/скрыть боковую панель библиотеки моделей",
+ "Toggle Node Library Sidebar": "Показать/скрыть боковую панель библиотеки узлов",
+ "Toggle Queue Sidebar": "Показать/скрыть боковую панель очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Показать/скрыть нижнюю панель терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle View Controls Bottom Panel": "Показать/скрыть нижнюю панель элементов управления",
+ "Toggle Workflows Sidebar": "Показать/скрыть боковую панель рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
@@ -819,7 +827,19 @@
"Unload Models and Execution Cache": "Выгрузить модели и кэш выполнения",
"Workflow": "Рабочий процесс",
"Zoom In": "Увеличить",
- "Zoom Out": "Уменьшить"
+ "Zoom Out": "Уменьшить",
+ "Zoom to fit": "Масштабировать по размеру"
+ },
+ "minimap": {
+ "nodeColors": "Цвета узлов",
+ "renderBypassState": "Отображать состояние обхода",
+ "renderErrorState": "Отображать состояние ошибки",
+ "showGroups": "Показать фреймы/группы",
+ "showLinks": "Показать связи",
+ "sideToolbar_modelLibrary": "sideToolbar.каталогМоделей",
+ "sideToolbar_nodeLibrary": "sideToolbar.каталогУзлов",
+ "sideToolbar_queue": "sideToolbar.очередь",
+ "sideToolbar_workflows": "sideToolbar.рабочиеПроцессы"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
@@ -1135,6 +1155,7 @@
"UV": "UV",
"User": "Пользователь",
"Validation": "Валидация",
+ "Vue Nodes": "Vue Nodes",
"Window": "Окно",
"Workflow": "Рабочий процесс"
},
@@ -1622,4 +1643,4 @@
"exportWorkflow": "Экспорт рабочего процесса",
"saveWorkflow": "Сохранить рабочий процесс"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/ru/settings.json b/src/locales/ru/settings.json
index 76e38da04d..48459266b4 100644
--- a/src/locales/ru/settings.json
+++ b/src/locales/ru/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Проверка рабочих процессов"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "Включить рендеринг узлов через Vue",
+ "tooltip": "Отображать узлы как компоненты Vue вместо элементов canvas. Экспериментальная функция."
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "Включить виджеты Vue",
+ "tooltip": "Отображать виджеты как компоненты Vue внутри узлов Vue."
+ },
"Comfy_WidgetControlMode": {
"name": "Режим управления виджетом",
"options": {
diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json
index c03e4bd4b6..b29b7deaf7 100644
--- a/src/locales/zh-TW/main.json
+++ b/src/locales/zh-TW/main.json
@@ -310,6 +310,8 @@
"disabling": "停用中",
"dismiss": "關閉",
"download": "下載",
+ "dropYourFileOr": "拖放您的檔案或",
+ "duplicate": "複製",
"edit": "編輯",
"empty": "空",
"enableAll": "全部啟用",
@@ -811,6 +813,12 @@
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle View Controls Bottom Panel": "切換檢視控制底部面板",
+ "Toggle Bottom Panel": "切換下方面板",
+ "Toggle Focus Mode": "切換專注模式",
+ "Toggle Model Library Sidebar": "切換模型庫側邊欄",
+ "Toggle Node Library Sidebar": "切換節點庫側邊欄",
+ "Toggle Queue Sidebar": "切換佇列側邊欄",
+ "Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
@@ -1135,6 +1143,7 @@
"UV": "UV",
"User": "使用者",
"Validation": "驗證",
+ "Vue Nodes": "Vue 節點",
"Window": "視窗",
"Workflow": "工作流程"
},
@@ -1622,4 +1631,4 @@
"exportWorkflow": "匯出工作流程",
"saveWorkflow": "儲存工作流程"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/zh-TW/settings.json b/src/locales/zh-TW/settings.json
index 375f751e62..06b0aefd3a 100644
--- a/src/locales/zh-TW/settings.json
+++ b/src/locales/zh-TW/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "驗證工作流程"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "啟用 Vue 節點渲染",
+ "tooltip": "將節點以 Vue 元件而非畫布元素方式渲染。實驗性功能。"
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "啟用 Vue 小工具",
+ "tooltip": "在 Vue 節點中以 Vue 元件渲染小工具。"
+ },
"Comfy_WidgetControlMode": {
"name": "元件控制模式",
"options": {
diff --git a/src/locales/zh/commands.json b/src/locales/zh/commands.json
index e7b51b6eb1..fb0578cf5a 100644
--- a/src/locales/zh/commands.json
+++ b/src/locales/zh/commands.json
@@ -279,13 +279,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
- "label": "切换基础底部面板"
+ "label": "切換基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
- "label": "切换视图控制底部面板"
+ "label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
- "label": "显示快捷键对话框"
+ "label": "顯示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index d98dbc008e..626c53c705 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -309,6 +309,7 @@
"disabling": "禁用中",
"dismiss": "关闭",
"download": "下载",
+ "dropYourFileOr": "拖放您的文件或",
"duplicate": "复制",
"edit": "编辑",
"empty": "空",
@@ -733,7 +734,7 @@
"instantTooltip": "工作流将会在生成完成后立即执行",
"interrupt": "取消当前任务",
"light": "淺色",
- "manageExtensions": "管理擴充功能",
+ "manageExtensions": "管理扩展功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"queue": "队列面板",
@@ -831,11 +832,14 @@
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Essential Bottom Panel": "切换基础底部面板",
+ "Toggle Bottom Panel": "切换底部面板",
+ "Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle View Controls Bottom Panel": "切换视图控制底部面板",
+ "Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
@@ -852,7 +856,11 @@
"renderBypassState": "渲染绕过状态",
"renderErrorState": "渲染错误状态",
"showGroups": "显示框架/分组",
- "showLinks": "显示连接"
+ "showLinks": "显示连接",
+ "sideToolbar_modelLibrary": "侧边工具栏.模型库",
+ "sideToolbar_nodeLibrary": "侧边工具栏.节点库",
+ "sideToolbar_queue": "侧边工具栏.队列",
+ "sideToolbar_workflows": "侧边工具栏.工作流"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
@@ -1169,6 +1177,7 @@
"UV": "UV",
"User": "用户",
"Validation": "验证",
+ "Vue Nodes": "Vue 节点",
"Window": "窗口",
"Workflow": "工作流"
},
@@ -1687,4 +1696,4 @@
"showMinimap": "显示小地图",
"zoomToFit": "适合画面"
}
-}
\ No newline at end of file
+}
diff --git a/src/locales/zh/settings.json b/src/locales/zh/settings.json
index 343454ce95..965a3cb428 100644
--- a/src/locales/zh/settings.json
+++ b/src/locales/zh/settings.json
@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "校验工作流"
},
+ "Comfy_VueNodes_Enabled": {
+ "name": "启用 Vue 节点渲染",
+ "tooltip": "将节点渲染为 Vue 组件,而不是画布元素。实验性功能。"
+ },
+ "Comfy_VueNodes_Widgets": {
+ "name": "启用Vue小部件",
+ "tooltip": "在Vue节点中将小部件渲染为Vue组件。"
+ },
"Comfy_WidgetControlMode": {
"name": "组件控制模式",
"options": {
diff --git a/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
new file mode 100644
index 0000000000..3019965852
--- /dev/null
+++ b/src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
@@ -0,0 +1,589 @@
+/**
+ * Litegraph Link Adapter
+ *
+ * Bridges the gap between litegraph's data model and the pure canvas renderer.
+ * Converts litegraph-specific types (LLink, LGraphNode, slots) into generic
+ * rendering data that can be consumed by the PathRenderer.
+ * Maintains backward compatibility with existing litegraph integration.
+ */
+import type { LGraph } from '@/lib/litegraph/src/LGraph'
+import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
+import { LLink } from '@/lib/litegraph/src/LLink'
+import type { Reroute } from '@/lib/litegraph/src/Reroute'
+import type {
+ CanvasColour,
+ INodeInputSlot,
+ INodeOutputSlot,
+ ReadOnlyPoint
+} from '@/lib/litegraph/src/interfaces'
+import { LiteGraph } from '@/lib/litegraph/src/litegraph'
+import {
+ LinkDirection,
+ LinkMarkerShape,
+ LinkRenderType
+} from '@/lib/litegraph/src/types/globalEnums'
+import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
+import {
+ type ArrowShape,
+ CanvasPathRenderer,
+ type Direction,
+ type DragLinkData,
+ type LinkRenderData,
+ type RenderContext as PathRenderContext,
+ type Point,
+ type RenderMode
+} from '@/renderer/core/canvas/pathRenderer'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import type { Bounds } from '@/renderer/core/layout/types'
+
+export interface LinkRenderContext {
+ // Canvas settings
+ renderMode: LinkRenderType
+ connectionWidth: number
+ renderBorder: boolean
+ lowQuality: boolean
+ highQualityRender: boolean
+ scale: number
+ linkMarkerShape: LinkMarkerShape
+ renderConnectionArrows: boolean
+
+ // State
+ highlightedLinks: Set
+
+ // Colors
+ defaultLinkColor: CanvasColour
+ linkTypeColors: Record
+
+ // Pattern for disabled links (optional)
+ disabledPattern?: CanvasPattern | null
+}
+
+export interface LinkRenderOptions {
+ color?: CanvasColour
+ flow?: boolean
+ skipBorder?: boolean
+ disabled?: boolean
+}
+
+export class LitegraphLinkAdapter {
+ private graph: LGraph
+ private pathRenderer: CanvasPathRenderer
+ public enableLayoutStoreWrites = true
+
+ constructor(graph: LGraph) {
+ this.graph = graph
+ this.pathRenderer = new CanvasPathRenderer()
+ }
+
+ /**
+ * Render a single link with all necessary data properly fetched
+ * Populates link.path for hit detection
+ */
+ renderLink(
+ ctx: CanvasRenderingContext2D,
+ link: LLink,
+ context: LinkRenderContext,
+ options: LinkRenderOptions = {}
+ ): void {
+ // Get nodes from graph
+ const sourceNode = this.graph.getNodeById(link.origin_id)
+ const targetNode = this.graph.getNodeById(link.target_id)
+
+ if (!sourceNode || !targetNode) {
+ console.warn(`Cannot render link ${link.id}: missing nodes`)
+ return
+ }
+
+ // Get slots from nodes
+ const sourceSlot = sourceNode.outputs?.[link.origin_slot]
+ const targetSlot = targetNode.inputs?.[link.target_slot]
+
+ if (!sourceSlot || !targetSlot) {
+ console.warn(`Cannot render link ${link.id}: missing slots`)
+ return
+ }
+
+ // Get positions using layout tree data if available
+ const startPos = getSlotPosition(
+ sourceNode,
+ link.origin_slot,
+ false // output
+ )
+ const endPos = getSlotPosition(
+ targetNode,
+ link.target_slot,
+ true // input
+ )
+
+ // Get directions from slots
+ const startDir = sourceSlot.dir || LinkDirection.RIGHT
+ const endDir = targetSlot.dir || LinkDirection.LEFT
+
+ // Convert to pure render data
+ const linkData = this.convertToLinkRenderData(
+ link,
+ { x: startPos[0], y: startPos[1] },
+ { x: endPos[0], y: endPos[1] },
+ startDir,
+ endDir,
+ options
+ )
+
+ // Convert context
+ const pathContext = this.convertToPathRenderContext(context)
+
+ // Render using pure renderer
+ const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
+
+ // Store path for hit detection
+ link.path = path
+
+ // Update layout store when writes are enabled (event-driven path)
+ if (this.enableLayoutStoreWrites && link.id !== -1) {
+ // Calculate bounds and center only when writing
+ const bounds = this.calculateLinkBounds(startPos, endPos, linkData)
+ const centerPos = linkData.centerPos || {
+ x: (startPos[0] + endPos[0]) / 2,
+ y: (startPos[1] + endPos[1]) / 2
+ }
+
+ layoutStore.updateLinkLayout(link.id, {
+ id: link.id,
+ path: path,
+ bounds: bounds,
+ centerPos: centerPos,
+ sourceNodeId: String(link.origin_id),
+ targetNodeId: String(link.target_id),
+ sourceSlot: link.origin_slot,
+ targetSlot: link.target_slot
+ })
+
+ // Also update segment layout for the whole link (null rerouteId means final segment)
+ layoutStore.updateLinkSegmentLayout(link.id, null, {
+ path: path,
+ bounds: bounds,
+ centerPos: centerPos
+ })
+ }
+ }
+
+ /**
+ * Convert litegraph link data to pure render format
+ */
+ private convertToLinkRenderData(
+ link: LLink,
+ startPoint: Point,
+ endPoint: Point,
+ startDir: LinkDirection,
+ endDir: LinkDirection,
+ options: LinkRenderOptions
+ ): LinkRenderData {
+ return {
+ id: String(link.id),
+ startPoint,
+ endPoint,
+ startDirection: this.convertDirection(startDir),
+ endDirection: this.convertDirection(endDir),
+ color: options.color
+ ? String(options.color)
+ : link.color
+ ? String(link.color)
+ : undefined,
+ type: link.type !== undefined ? String(link.type) : undefined,
+ flow: options.flow || false,
+ disabled: options.disabled || false
+ }
+ }
+
+ /**
+ * Convert LinkDirection enum to Direction string
+ */
+ private convertDirection(dir: LinkDirection): Direction {
+ switch (dir) {
+ case LinkDirection.LEFT:
+ return 'left'
+ case LinkDirection.RIGHT:
+ return 'right'
+ case LinkDirection.UP:
+ return 'up'
+ case LinkDirection.DOWN:
+ return 'down'
+ default:
+ return 'right'
+ }
+ }
+
+ /**
+ * Convert LinkRenderContext to PathRenderContext
+ */
+ private convertToPathRenderContext(
+ context: LinkRenderContext
+ ): PathRenderContext {
+ // Match original arrow rendering conditions:
+ // Arrows only render when scale >= 0.6 AND highquality_render AND render_connection_arrows
+ const shouldShowArrows =
+ context.scale >= 0.6 &&
+ context.highQualityRender &&
+ context.renderConnectionArrows
+
+ // Only show center marker when not set to None
+ const shouldShowCenterMarker =
+ context.linkMarkerShape !== LinkMarkerShape.None
+
+ return {
+ style: {
+ mode: this.convertRenderMode(context.renderMode),
+ connectionWidth: context.connectionWidth,
+ borderWidth: context.renderBorder ? 4 : undefined,
+ arrowShape: this.convertArrowShape(context.linkMarkerShape),
+ showArrows: shouldShowArrows,
+ lowQuality: context.lowQuality,
+ // Center marker settings (matches original litegraph behavior)
+ showCenterMarker: shouldShowCenterMarker,
+ centerMarkerShape:
+ context.linkMarkerShape === LinkMarkerShape.Arrow
+ ? 'arrow'
+ : 'circle',
+ highQuality: context.highQualityRender
+ },
+ colors: {
+ default: String(context.defaultLinkColor),
+ byType: this.convertColorMap(context.linkTypeColors),
+ highlighted: '#FFF'
+ },
+ patterns: {
+ disabled: context.disabledPattern
+ },
+ animation: {
+ time: LiteGraph.getTime() * 0.001
+ },
+ scale: context.scale,
+ highlightedIds: new Set(Array.from(context.highlightedLinks).map(String))
+ }
+ }
+
+ /**
+ * Convert LinkRenderType to RenderMode
+ */
+ private convertRenderMode(mode: LinkRenderType): RenderMode {
+ switch (mode) {
+ case LinkRenderType.LINEAR_LINK:
+ return 'linear'
+ case LinkRenderType.STRAIGHT_LINK:
+ return 'straight'
+ case LinkRenderType.SPLINE_LINK:
+ default:
+ return 'spline'
+ }
+ }
+
+ /**
+ * Convert LinkMarkerShape to ArrowShape
+ */
+ private convertArrowShape(shape: LinkMarkerShape): ArrowShape {
+ switch (shape) {
+ case LinkMarkerShape.Circle:
+ return 'circle'
+ case LinkMarkerShape.Arrow:
+ default:
+ return 'triangle'
+ }
+ }
+
+ /**
+ * Convert color map to ensure all values are strings
+ */
+ private convertColorMap(
+ colors: Record
+ ): Record {
+ const result: Record = {}
+ for (const [key, value] of Object.entries(colors)) {
+ result[key] = String(value)
+ }
+ return result
+ }
+
+ /**
+ * Apply spline offset to a point, mimicking original #addSplineOffset behavior
+ * Critically: does nothing for CENTER/NONE directions (no case for them)
+ */
+ private applySplineOffset(
+ point: Point,
+ direction: LinkDirection,
+ distance: number
+ ): void {
+ switch (direction) {
+ case LinkDirection.LEFT:
+ point.x -= distance
+ break
+ case LinkDirection.RIGHT:
+ point.x += distance
+ break
+ case LinkDirection.UP:
+ point.y -= distance
+ break
+ case LinkDirection.DOWN:
+ point.y += distance
+ break
+ // CENTER and NONE: no offset applied (original behavior)
+ }
+ }
+
+ /**
+ * Direct rendering method compatible with LGraphCanvas
+ * Converts data and delegates to pure renderer
+ */
+ renderLinkDirect(
+ ctx: CanvasRenderingContext2D,
+ a: ReadOnlyPoint,
+ b: ReadOnlyPoint,
+ link: LLink | null,
+ skip_border: boolean,
+ flow: number | boolean | null,
+ color: CanvasColour | null,
+ start_dir: LinkDirection,
+ end_dir: LinkDirection,
+ context: LinkRenderContext,
+ extras: {
+ reroute?: Reroute
+ startControl?: ReadOnlyPoint
+ endControl?: ReadOnlyPoint
+ num_sublines?: number
+ disabled?: boolean
+ } = {}
+ ): void {
+ // Apply same defaults as original renderLink
+ const startDir = start_dir || LinkDirection.RIGHT
+ const endDir = end_dir || LinkDirection.LEFT
+
+ // Convert flow to boolean
+ const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
+
+ // Create LinkRenderData from direct parameters
+ const linkData: LinkRenderData = {
+ id: link ? String(link.id) : 'temp',
+ startPoint: { x: a[0], y: a[1] },
+ endPoint: { x: b[0], y: b[1] },
+ startDirection: this.convertDirection(startDir),
+ endDirection: this.convertDirection(endDir),
+ color: color !== null && color !== undefined ? String(color) : undefined,
+ type: link?.type !== undefined ? String(link.type) : undefined,
+ flow: flowBool,
+ disabled: extras.disabled || false
+ }
+
+ // Control points handling (spline mode):
+ // - Pre-refactor, the old renderLink honored a single provided control and
+ // derived the missing side via #addSplineOffset (CENTER => no offset).
+ // - Restore that behavior here so reroute segments render identically.
+ if (context.renderMode === LinkRenderType.SPLINE_LINK) {
+ const hasStartCtrl = !!extras.startControl
+ const hasEndCtrl = !!extras.endControl
+
+ // Compute distance once for offsets
+ const dist = Math.sqrt(
+ (b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
+ )
+ const factor = 0.25
+
+ const cps: Point[] = []
+
+ if (hasStartCtrl && hasEndCtrl) {
+ // Both provided explicitly
+ cps.push(
+ {
+ x: a[0] + (extras.startControl![0] || 0),
+ y: a[1] + (extras.startControl![1] || 0)
+ },
+ {
+ x: b[0] + (extras.endControl![0] || 0),
+ y: b[1] + (extras.endControl![1] || 0)
+ }
+ )
+ linkData.controlPoints = cps
+ } else if (hasStartCtrl && !hasEndCtrl) {
+ // Start provided, derive end via direction offset (CENTER => no offset)
+ const start = {
+ x: a[0] + (extras.startControl![0] || 0),
+ y: a[1] + (extras.startControl![1] || 0)
+ }
+ const end = { x: b[0], y: b[1] }
+ this.applySplineOffset(end, endDir, dist * factor)
+ cps.push(start, end)
+ linkData.controlPoints = cps
+ } else if (!hasStartCtrl && hasEndCtrl) {
+ // End provided, derive start via direction offset (CENTER => no offset)
+ const start = { x: a[0], y: a[1] }
+ this.applySplineOffset(start, startDir, dist * factor)
+ const end = {
+ x: b[0] + (extras.endControl![0] || 0),
+ y: b[1] + (extras.endControl![1] || 0)
+ }
+ cps.push(start, end)
+ linkData.controlPoints = cps
+ } else {
+ // Neither provided: derive both from directions (CENTER => no offset)
+ const start = { x: a[0], y: a[1] }
+ const end = { x: b[0], y: b[1] }
+ this.applySplineOffset(start, startDir, dist * factor)
+ this.applySplineOffset(end, endDir, dist * factor)
+ cps.push(start, end)
+ linkData.controlPoints = cps
+ }
+ }
+
+ // Convert context
+ const pathContext = this.convertToPathRenderContext(context)
+
+ // Override skip_border if needed
+ if (skip_border) {
+ pathContext.style.borderWidth = undefined
+ }
+
+ // Render using pure renderer
+ const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
+
+ // Store path for hit detection
+ const linkSegment = extras.reroute ?? link
+ if (linkSegment) {
+ linkSegment.path = path
+
+ // Copy calculated center position back to litegraph object
+ // This is needed for hit detection and menu interaction
+ if (linkData.centerPos) {
+ linkSegment._pos = linkSegment._pos || new Float32Array(2)
+ linkSegment._pos[0] = linkData.centerPos.x
+ linkSegment._pos[1] = linkData.centerPos.y
+
+ // Store center angle if calculated (for arrow markers)
+ if (linkData.centerAngle !== undefined) {
+ linkSegment._centreAngle = linkData.centerAngle
+ }
+ }
+
+ // Update layout store when writes are enabled (event-driven path)
+ if (this.enableLayoutStoreWrites && link && link.id !== -1) {
+ // Calculate bounds and center only when writing
+ const bounds = this.calculateLinkBounds(
+ [linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
+ [linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
+ linkData
+ )
+ const centerPos = linkData.centerPos || {
+ x: (linkData.startPoint.x + linkData.endPoint.x) / 2,
+ y: (linkData.startPoint.y + linkData.endPoint.y) / 2
+ }
+
+ // Update whole link layout (only if not a reroute segment)
+ if (!extras.reroute) {
+ layoutStore.updateLinkLayout(link.id, {
+ id: link.id,
+ path: path,
+ bounds: bounds,
+ centerPos: centerPos,
+ sourceNodeId: String(link.origin_id),
+ targetNodeId: String(link.target_id),
+ sourceSlot: link.origin_slot,
+ targetSlot: link.target_slot
+ })
+ }
+
+ // Always update segment layout (for both regular links and reroute segments)
+ const rerouteId = extras.reroute ? extras.reroute.id : null
+ layoutStore.updateLinkSegmentLayout(link.id, rerouteId, {
+ path: path,
+ bounds: bounds,
+ centerPos: centerPos
+ })
+ }
+ }
+ }
+
+ /**
+ * Render a link being dragged from a slot to mouse position
+ * Used during link creation/reconnection
+ */
+ renderDraggingLink(
+ ctx: CanvasRenderingContext2D,
+ fromNode: LGraphNode | null,
+ fromSlot: INodeOutputSlot | INodeInputSlot,
+ fromSlotIndex: number,
+ toPosition: ReadOnlyPoint,
+ context: LinkRenderContext,
+ options: {
+ fromInput?: boolean
+ color?: CanvasColour
+ disabled?: boolean
+ } = {}
+ ): void {
+ if (!fromNode) return
+
+ // Get slot position using layout tree if available
+ const slotPos = getSlotPosition(
+ fromNode,
+ fromSlotIndex,
+ options.fromInput || false
+ )
+ if (!slotPos) return
+
+ // Get slot direction
+ const slotDir =
+ fromSlot.dir ||
+ (options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
+
+ // Create drag data
+ const dragData: DragLinkData = {
+ fixedPoint: { x: slotPos[0], y: slotPos[1] },
+ fixedDirection: this.convertDirection(slotDir),
+ dragPoint: { x: toPosition[0], y: toPosition[1] },
+ color: options.color ? String(options.color) : undefined,
+ type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
+ disabled: options.disabled || false,
+ fromInput: options.fromInput || false
+ }
+
+ // Convert context
+ const pathContext = this.convertToPathRenderContext(context)
+
+ // Hide center marker when dragging links
+ pathContext.style.showCenterMarker = false
+
+ // Render using pure renderer
+ this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
+ }
+
+ /**
+ * Calculate bounding box for a link
+ * Includes padding for line width and control points
+ */
+ private calculateLinkBounds(
+ startPos: ReadOnlyPoint,
+ endPos: ReadOnlyPoint,
+ linkData: LinkRenderData
+ ): Bounds {
+ let minX = Math.min(startPos[0], endPos[0])
+ let maxX = Math.max(startPos[0], endPos[0])
+ let minY = Math.min(startPos[1], endPos[1])
+ let maxY = Math.max(startPos[1], endPos[1])
+
+ // Include control points if they exist (for spline links)
+ if (linkData.controlPoints) {
+ for (const cp of linkData.controlPoints) {
+ minX = Math.min(minX, cp.x)
+ maxX = Math.max(maxX, cp.x)
+ minY = Math.min(minY, cp.y)
+ maxY = Math.max(maxY, cp.y)
+ }
+ }
+
+ // Add padding for line width and hit tolerance
+ const padding = 20
+
+ return {
+ x: minX - padding,
+ y: minY - padding,
+ width: maxX - minX + 2 * padding,
+ height: maxY - minY + 2 * padding
+ }
+ }
+}
diff --git a/src/renderer/core/canvas/litegraph/slotCalculations.ts b/src/renderer/core/canvas/litegraph/slotCalculations.ts
new file mode 100644
index 0000000000..b56d95b970
--- /dev/null
+++ b/src/renderer/core/canvas/litegraph/slotCalculations.ts
@@ -0,0 +1,283 @@
+/**
+ * Slot Position Calculations
+ *
+ * Centralized utility for calculating input/output slot positions on nodes.
+ * This allows both litegraph nodes and the layout system to use the same
+ * calculation logic while providing their own position data.
+ */
+import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
+import type {
+ INodeInputSlot,
+ INodeOutputSlot,
+ INodeSlot,
+ Point,
+ ReadOnlyPoint
+} from '@/lib/litegraph/src/interfaces'
+import { LiteGraph } from '@/lib/litegraph/src/litegraph'
+import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
+import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+
+export interface SlotPositionContext {
+ /** Node's X position in graph coordinates */
+ nodeX: number
+ /** Node's Y position in graph coordinates */
+ nodeY: number
+ /** Node's width */
+ nodeWidth: number
+ /** Node's height */
+ nodeHeight: number
+ /** Whether the node is collapsed */
+ collapsed: boolean
+ /** Collapsed width (if applicable) */
+ collapsedWidth?: number
+ /** Node constructor's slot_start_y offset */
+ slotStartY?: number
+ /** Node's input slots */
+ inputs: INodeInputSlot[]
+ /** Node's output slots */
+ outputs: INodeOutputSlot[]
+ /** Node's widgets (for widget slot detection) */
+ widgets?: Array<{ name?: string }>
+}
+
+/**
+ * Calculate the position of an input slot in graph coordinates
+ * @param context Node context containing position and slot data
+ * @param slot The input slot index
+ * @returns Position of the input slot center in graph coordinates
+ */
+export function calculateInputSlotPos(
+ context: SlotPositionContext,
+ slot: number
+): Point {
+ const input = context.inputs[slot]
+ if (!input) return [context.nodeX, context.nodeY]
+
+ return calculateInputSlotPosFromSlot(context, input)
+}
+
+/**
+ * Calculate the position of an input slot in graph coordinates
+ * @param context Node context containing position and slot data
+ * @param input The input slot object
+ * @returns Position of the input slot center in graph coordinates
+ */
+export function calculateInputSlotPosFromSlot(
+ context: SlotPositionContext,
+ input: INodeInputSlot
+): Point {
+ const { nodeX, nodeY, collapsed } = context
+
+ // Handle collapsed nodes
+ if (collapsed) {
+ const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
+ return [nodeX, nodeY - halfTitle]
+ }
+
+ // Handle hard-coded positions
+ const { pos } = input
+ if (pos) return [nodeX + pos[0], nodeY + pos[1]]
+
+ // Check if we should use Vue positioning
+ if (LiteGraph.vueNodesMode) {
+ if (isWidgetInputSlot(input)) {
+ // Widget slot - pass the slot object
+ return calculateVueSlotPosition(context, true, input, -1)
+ } else {
+ // Regular slot - find its index in default vertical inputs
+ const defaultVerticalInputs = getDefaultVerticalInputs(context)
+ const slotIndex = defaultVerticalInputs.indexOf(input)
+ if (slotIndex !== -1) {
+ return calculateVueSlotPosition(context, true, input, slotIndex)
+ }
+ }
+ }
+
+ // Default vertical slots
+ const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
+ const nodeOffsetY = context.slotStartY || 0
+ const defaultVerticalInputs = getDefaultVerticalInputs(context)
+ const slotIndex = defaultVerticalInputs.indexOf(input)
+ const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
+
+ return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
+}
+
+/**
+ * Calculate the position of an output slot in graph coordinates
+ * @param context Node context containing position and slot data
+ * @param slot The output slot index
+ * @returns Position of the output slot center in graph coordinates
+ */
+export function calculateOutputSlotPos(
+ context: SlotPositionContext,
+ slot: number
+): Point {
+ const { nodeX, nodeY, nodeWidth, collapsed, collapsedWidth, outputs } =
+ context
+
+ // Handle collapsed nodes
+ if (collapsed) {
+ const width = collapsedWidth || LiteGraph.NODE_COLLAPSED_WIDTH
+ const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
+ return [nodeX + width, nodeY - halfTitle]
+ }
+
+ const outputSlot = outputs[slot]
+ if (!outputSlot) return [nodeX + nodeWidth, nodeY]
+
+ // Handle hard-coded positions
+ const outputPos = outputSlot.pos
+ if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
+
+ // Check if we should use Vue positioning
+ if (LiteGraph.vueNodesMode) {
+ const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
+ const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
+ if (slotIndex !== -1) {
+ return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
+ }
+ }
+
+ // Default vertical slots
+ const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
+ const nodeOffsetY = context.slotStartY || 0
+ const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
+ const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
+ const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
+
+ // TODO: Why +1?
+ return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY]
+}
+
+/**
+ * Get slot position using layout tree if available, fallback to node's position
+ * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
+ * @param node The LGraphNode
+ * @param slotIndex The slot index
+ * @param isInput Whether this is an input slot
+ * @returns Position of the slot center in graph coordinates
+ */
+export function getSlotPosition(
+ node: LGraphNode,
+ slotIndex: number,
+ isInput: boolean
+): ReadOnlyPoint {
+ // Try to get precise position from slot layout (DOM-registered)
+ const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
+ const slotLayout = layoutStore.getSlotLayout(slotKey)
+ if (slotLayout) {
+ return [slotLayout.position.x, slotLayout.position.y]
+ }
+
+ // Fallback: derive position from node layout tree and slot model
+ const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
+
+ if (nodeLayout) {
+ // Create context from layout tree data
+ const context: SlotPositionContext = {
+ nodeX: nodeLayout.position.x,
+ nodeY: nodeLayout.position.y,
+ nodeWidth: nodeLayout.size.width,
+ nodeHeight: nodeLayout.size.height,
+ collapsed: node.flags.collapsed || false,
+ collapsedWidth: node._collapsed_width,
+ slotStartY: node.constructor.slot_start_y,
+ inputs: node.inputs,
+ outputs: node.outputs,
+ widgets: node.widgets
+ }
+
+ // Use helper to calculate position
+ return isInput
+ ? calculateInputSlotPos(context, slotIndex)
+ : calculateOutputSlotPos(context, slotIndex)
+ }
+
+ // Fallback to node's own methods if layout not available
+ return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
+}
+
+/**
+ * Get the inputs that are not positioned with absolute coordinates
+ */
+function getDefaultVerticalInputs(
+ context: SlotPositionContext
+): INodeInputSlot[] {
+ return context.inputs.filter(
+ (slot) => !slot.pos && !(context.widgets?.length && isWidgetInputSlot(slot))
+ )
+}
+
+/**
+ * Get the outputs that are not positioned with absolute coordinates
+ */
+function getDefaultVerticalOutputs(
+ context: SlotPositionContext
+): INodeOutputSlot[] {
+ return context.outputs.filter((slot) => !slot.pos)
+}
+
+/**
+ * Calculate slot position using Vue node dimensions.
+ * This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
+ * @param context Node context
+ * @param isInput Whether this is an input slot (true) or output slot (false)
+ * @param slot The slot object (for widget detection)
+ * @param slotIndex The index of the slot in the appropriate array
+ * @returns The [x, y] position of the slot center in graph coordinates
+ */
+function calculateVueSlotPosition(
+ context: SlotPositionContext,
+ isInput: boolean,
+ slot: INodeSlot,
+ slotIndex: number
+): Point {
+ const { nodeX, nodeY, nodeWidth, widgets } = context
+ const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
+ const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
+
+ let slotCenterY: number
+
+ // IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
+ // The header is rendered ABOVE this position at negative Y coordinates
+ // So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
+ const headerDifference =
+ dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
+
+ if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
+ // Widget input slot - calculate based on widget position
+ // Count regular (non-widget) input slots
+ const regularInputCount = getDefaultVerticalInputs(context).length
+
+ // Find widget index
+ const widgetIndex =
+ widgets?.findIndex(
+ (w) => w.name === (slot as INodeInputSlot).widget?.name
+ ) ?? 0
+
+ // Y position relative to the node body top (not the header)
+ slotCenterY =
+ headerDifference +
+ regularInputCount * dimensions.SLOT_HEIGHT +
+ (regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
+ widgetIndex *
+ (dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
+ dimensions.STANDARD_WIDGET_HEIGHT / 2
+ } else {
+ // Regular slot (input or output)
+ // Slots start at the top of the body, but we need to account for Vue's larger header
+ slotCenterY =
+ headerDifference +
+ slotIndex * dimensions.SLOT_HEIGHT +
+ dimensions.SLOT_HEIGHT / 2
+ }
+
+ // Calculate X position
+ // Input slots: 10px from left edge (center of 20x20 connector)
+ // Output slots: 10px from right edge (center of 20x20 connector)
+ const slotCenterX = isInput ? 10 : nodeWidth - 10
+
+ return [nodeX + slotCenterX, nodeY + slotCenterY]
+}
diff --git a/src/renderer/core/canvas/pathRenderer.ts b/src/renderer/core/canvas/pathRenderer.ts
new file mode 100644
index 0000000000..a2ee8817c9
--- /dev/null
+++ b/src/renderer/core/canvas/pathRenderer.ts
@@ -0,0 +1,820 @@
+/**
+ * Path Renderer
+ *
+ * Pure canvas2D rendering utility with no framework dependencies.
+ * Renders bezier curves, straight lines, and linear connections between points.
+ * Supports arrows, flow animations, and returns Path2D objects for hit detection.
+ * Can be reused in any canvas-based project without modification.
+ */
+
+export interface Point {
+ x: number
+ y: number
+}
+
+export type Direction = 'left' | 'right' | 'up' | 'down'
+export type RenderMode = 'spline' | 'straight' | 'linear'
+export type ArrowShape = 'triangle' | 'circle' | 'square'
+
+export interface LinkRenderData {
+ id: string
+ startPoint: Point
+ endPoint: Point
+ startDirection: Direction
+ endDirection: Direction
+ color?: string
+ type?: string
+ controlPoints?: Point[]
+ flow?: boolean
+ disabled?: boolean
+ // Optional multi-segment support
+ segments?: Array<{
+ start: Point
+ end: Point
+ controlPoints?: Point[]
+ }>
+ // Center point storage (for hit detection and menu)
+ centerPos?: Point
+ centerAngle?: number
+}
+
+export interface RenderStyle {
+ mode: RenderMode
+ connectionWidth: number
+ borderWidth?: number
+ arrowShape?: ArrowShape
+ showArrows?: boolean
+ lowQuality?: boolean
+ // Center marker properties
+ showCenterMarker?: boolean
+ centerMarkerShape?: 'circle' | 'arrow'
+ highQuality?: boolean
+}
+
+export interface RenderColors {
+ default: string
+ byType: Record
+ highlighted: string
+}
+
+export interface RenderContext {
+ style: RenderStyle
+ colors: RenderColors
+ patterns?: {
+ disabled?: CanvasPattern | null
+ }
+ animation?: {
+ time: number // Seconds for flow animation
+ }
+ scale?: number // Canvas scale for quality adjustments
+ highlightedIds?: Set
+}
+
+export interface DragLinkData {
+ /** Fixed end - the slot being dragged from */
+ fixedPoint: Point
+ fixedDirection: Direction
+ /** Moving end - follows mouse */
+ dragPoint: Point
+ dragDirection?: Direction
+ /** Visual properties */
+ color?: string
+ type?: string
+ disabled?: boolean
+ /** Whether dragging from input (reverse direction) */
+ fromInput?: boolean
+}
+
+export class CanvasPathRenderer {
+ /**
+ * Draw a link between two points
+ * Returns a Path2D object for hit detection
+ */
+ drawLink(
+ ctx: CanvasRenderingContext2D,
+ link: LinkRenderData,
+ context: RenderContext
+ ): Path2D {
+ const path = new Path2D()
+
+ // Determine final color
+ const isHighlighted = context.highlightedIds?.has(link.id) ?? false
+ const color = this.determineLinkColor(link, context, isHighlighted)
+
+ // Save context state
+ ctx.save()
+
+ // Apply disabled pattern if needed
+ if (link.disabled && context.patterns?.disabled) {
+ ctx.strokeStyle = context.patterns.disabled
+ } else {
+ ctx.strokeStyle = color
+ }
+
+ // Set line properties
+ ctx.lineWidth = context.style.connectionWidth
+ ctx.lineJoin = 'round'
+
+ // Draw border if needed
+ if (context.style.borderWidth && !context.style.lowQuality) {
+ this.drawLinkPath(
+ ctx,
+ path,
+ link,
+ context,
+ context.style.connectionWidth + context.style.borderWidth,
+ 'rgba(0,0,0,0.5)'
+ )
+ }
+
+ // Draw main link
+ this.drawLinkPath(
+ ctx,
+ path,
+ link,
+ context,
+ context.style.connectionWidth,
+ color
+ )
+
+ // Calculate and store center position
+ this.calculateCenterPoint(link, context)
+
+ // Draw arrows if needed
+ if (context.style.showArrows) {
+ this.drawArrows(ctx, link, context, color)
+ }
+
+ // Draw center marker if needed (for link menu interaction)
+ if (
+ context.style.showCenterMarker &&
+ context.scale &&
+ context.scale >= 0.6 &&
+ context.style.highQuality
+ ) {
+ this.drawCenterMarker(ctx, link, context, color)
+ }
+
+ // Draw flow animation if needed
+ if (link.flow && context.animation) {
+ this.drawFlowAnimation(ctx, path, link, context)
+ }
+
+ ctx.restore()
+
+ return path
+ }
+
+ private determineLinkColor(
+ link: LinkRenderData,
+ context: RenderContext,
+ isHighlighted: boolean
+ ): string {
+ if (isHighlighted) {
+ return context.colors.highlighted
+ }
+ if (link.color) {
+ return link.color
+ }
+ if (link.type && context.colors.byType[link.type]) {
+ return context.colors.byType[link.type]
+ }
+ return context.colors.default
+ }
+
+ private drawLinkPath(
+ ctx: CanvasRenderingContext2D,
+ path: Path2D,
+ link: LinkRenderData,
+ context: RenderContext,
+ lineWidth: number,
+ color: string
+ ): void {
+ ctx.strokeStyle = color
+ ctx.lineWidth = lineWidth
+
+ const start = link.startPoint
+ const end = link.endPoint
+
+ // Build the path based on render mode
+ if (context.style.mode === 'linear') {
+ this.buildLinearPath(
+ path,
+ start,
+ end,
+ link.startDirection,
+ link.endDirection
+ )
+ } else if (context.style.mode === 'straight') {
+ this.buildStraightPath(
+ path,
+ start,
+ end,
+ link.startDirection,
+ link.endDirection
+ )
+ } else {
+ // Spline mode (default)
+ this.buildSplinePath(
+ path,
+ start,
+ end,
+ link.startDirection,
+ link.endDirection,
+ link.controlPoints
+ )
+ }
+
+ ctx.stroke(path)
+ }
+
+ private buildLinearPath(
+ path: Path2D,
+ start: Point,
+ end: Point,
+ startDir: Direction,
+ endDir: Direction
+ ): void {
+ // Match original litegraph LINEAR_LINK mode with 4-point path
+ const l = 15 // offset distance for control points
+
+ const innerA = { x: start.x, y: start.y }
+ const innerB = { x: end.x, y: end.y }
+
+ // Apply directional offsets to create control points
+ switch (startDir) {
+ case 'left':
+ innerA.x -= l
+ break
+ case 'right':
+ innerA.x += l
+ break
+ case 'up':
+ innerA.y -= l
+ break
+ case 'down':
+ innerA.y += l
+ break
+ }
+
+ switch (endDir) {
+ case 'left':
+ innerB.x -= l
+ break
+ case 'right':
+ innerB.x += l
+ break
+ case 'up':
+ innerB.y -= l
+ break
+ case 'down':
+ innerB.y += l
+ break
+ }
+
+ // Draw 4-point path: start -> innerA -> innerB -> end
+ path.moveTo(start.x, start.y)
+ path.lineTo(innerA.x, innerA.y)
+ path.lineTo(innerB.x, innerB.y)
+ path.lineTo(end.x, end.y)
+ }
+
+ private buildStraightPath(
+ path: Path2D,
+ start: Point,
+ end: Point,
+ startDir: Direction,
+ endDir: Direction
+ ): void {
+ // Match original STRAIGHT_LINK implementation with l=10 offset
+ const l = 10 // offset distance matching original
+
+ const innerA = { x: start.x, y: start.y }
+ const innerB = { x: end.x, y: end.y }
+
+ // Apply directional offsets to match original behavior
+ switch (startDir) {
+ case 'left':
+ innerA.x -= l
+ break
+ case 'right':
+ innerA.x += l
+ break
+ case 'up':
+ innerA.y -= l
+ break
+ case 'down':
+ innerA.y += l
+ break
+ }
+
+ switch (endDir) {
+ case 'left':
+ innerB.x -= l
+ break
+ case 'right':
+ innerB.x += l
+ break
+ case 'up':
+ innerB.y -= l
+ break
+ case 'down':
+ innerB.y += l
+ break
+ }
+
+ // Calculate midpoint using innerA/innerB positions (matching original)
+ const midX = (innerA.x + innerB.x) * 0.5
+
+ // Build path: start -> innerA -> (midX, innerA.y) -> (midX, innerB.y) -> innerB -> end
+ path.moveTo(start.x, start.y)
+ path.lineTo(innerA.x, innerA.y)
+ path.lineTo(midX, innerA.y)
+ path.lineTo(midX, innerB.y)
+ path.lineTo(innerB.x, innerB.y)
+ path.lineTo(end.x, end.y)
+ }
+
+ private buildSplinePath(
+ path: Path2D,
+ start: Point,
+ end: Point,
+ startDir: Direction,
+ endDir: Direction,
+ controlPoints?: Point[]
+ ): void {
+ path.moveTo(start.x, start.y)
+
+ // Calculate control points if not provided
+ const controls =
+ controlPoints || this.calculateControlPoints(start, end, startDir, endDir)
+
+ if (controls.length >= 2) {
+ // Cubic bezier
+ path.bezierCurveTo(
+ controls[0].x,
+ controls[0].y,
+ controls[1].x,
+ controls[1].y,
+ end.x,
+ end.y
+ )
+ } else if (controls.length === 1) {
+ // Quadratic bezier
+ path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y)
+ } else {
+ // Fallback to linear
+ path.lineTo(end.x, end.y)
+ }
+ }
+
+ private calculateControlPoints(
+ start: Point,
+ end: Point,
+ startDir: Direction,
+ endDir: Direction
+ ): Point[] {
+ const dist = Math.sqrt(
+ Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
+ )
+ const controlDist = Math.max(30, dist * 0.25)
+
+ // Calculate control point offsets based on direction
+ const startControl = this.getDirectionOffset(startDir, controlDist)
+ const endControl = this.getDirectionOffset(endDir, controlDist)
+
+ return [
+ { x: start.x + startControl.x, y: start.y + startControl.y },
+ { x: end.x + endControl.x, y: end.y + endControl.y }
+ ]
+ }
+
+ private getDirectionOffset(direction: Direction, distance: number): Point {
+ switch (direction) {
+ case 'left':
+ return { x: -distance, y: 0 }
+ case 'right':
+ return { x: distance, y: 0 }
+ case 'up':
+ return { x: 0, y: -distance }
+ case 'down':
+ return { x: 0, y: distance }
+ }
+ }
+
+ private drawArrows(
+ ctx: CanvasRenderingContext2D,
+ link: LinkRenderData,
+ context: RenderContext,
+ color: string
+ ): void {
+ if (!context.style.showArrows) return
+
+ // Render arrows at 0.25 and 0.75 positions along the path (matching original)
+ const positions = [0.25, 0.75]
+
+ for (const t of positions) {
+ // Compute arrow position and angle
+ const posA = this.computeConnectionPoint(link, t, context)
+ const posB = this.computeConnectionPoint(link, t + 0.01, context) // slightly ahead for angle
+
+ const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
+
+ // Draw arrow triangle (matching original shape)
+ const transform = ctx.getTransform()
+ ctx.translate(posA.x, posA.y)
+ ctx.rotate(angle)
+ ctx.fillStyle = color
+ ctx.beginPath()
+ ctx.moveTo(-5, -3)
+ ctx.lineTo(0, +7)
+ ctx.lineTo(+5, -3)
+ ctx.fill()
+ ctx.setTransform(transform)
+ }
+ }
+
+ /**
+ * Compute a point along the link path at position t (0 to 1)
+ * For backward compatibility with original litegraph, this always uses
+ * bezier calculation with spline offsets, regardless of render mode.
+ * This ensures arrow positions match the original implementation.
+ */
+ private computeConnectionPoint(
+ link: LinkRenderData,
+ t: number,
+ _context: RenderContext
+ ): Point {
+ const { startPoint, endPoint, startDirection, endDirection } = link
+
+ // Match original behavior: always use bezier math with spline offsets
+ // regardless of render mode (for arrow position compatibility)
+ const dist = Math.sqrt(
+ Math.pow(endPoint.x - startPoint.x, 2) +
+ Math.pow(endPoint.y - startPoint.y, 2)
+ )
+ const factor = 0.25
+
+ // Create control points with spline offsets (matching original #addSplineOffset)
+ const pa = { x: startPoint.x, y: startPoint.y }
+ const pb = { x: endPoint.x, y: endPoint.y }
+
+ // Apply spline offsets based on direction
+ switch (startDirection) {
+ case 'left':
+ pa.x -= dist * factor
+ break
+ case 'right':
+ pa.x += dist * factor
+ break
+ case 'up':
+ pa.y -= dist * factor
+ break
+ case 'down':
+ pa.y += dist * factor
+ break
+ }
+
+ switch (endDirection) {
+ case 'left':
+ pb.x -= dist * factor
+ break
+ case 'right':
+ pb.x += dist * factor
+ break
+ case 'up':
+ pb.y -= dist * factor
+ break
+ case 'down':
+ pb.y += dist * factor
+ break
+ }
+
+ // Calculate bezier point (matching original computeConnectionPoint)
+ 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
+
+ return {
+ x: c1 * startPoint.x + c2 * pa.x + c3 * pb.x + c4 * endPoint.x,
+ y: c1 * startPoint.y + c2 * pa.y + c3 * pb.y + c4 * endPoint.y
+ }
+ }
+
+ private drawFlowAnimation(
+ ctx: CanvasRenderingContext2D,
+ _path: Path2D,
+ link: LinkRenderData,
+ context: RenderContext
+ ): void {
+ if (!context.animation) return
+
+ // Match original implementation: render 5 moving circles along the path
+ const time = context.animation.time
+ const linkColor = this.determineLinkColor(link, context, false)
+
+ ctx.save()
+ ctx.fillStyle = linkColor
+
+ // Draw 5 circles at different positions along the path
+ for (let i = 0; i < 5; ++i) {
+ // Calculate position along path (0 to 1), with time-based animation
+ const f = (time + i * 0.2) % 1
+ const flowPos = this.computeConnectionPoint(link, f, context)
+
+ // Draw circle at this position
+ ctx.beginPath()
+ ctx.arc(flowPos.x, flowPos.y, 5, 0, 2 * Math.PI)
+ ctx.fill()
+ }
+
+ ctx.restore()
+ }
+
+ /**
+ * Utility to find a point on a bezier curve (for hit detection)
+ */
+ findPointOnBezier(
+ t: number,
+ p0: Point,
+ p1: Point,
+ p2: Point,
+ p3: Point
+ ): Point {
+ const mt = 1 - t
+ const mt2 = mt * mt
+ const mt3 = mt2 * mt
+ const t2 = t * t
+ const t3 = t2 * t
+
+ return {
+ x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
+ y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
+ }
+ }
+
+ /**
+ * Draw a link being dragged from a slot to the mouse position
+ * Returns a Path2D object for potential hit detection
+ */
+ drawDraggingLink(
+ ctx: CanvasRenderingContext2D,
+ dragData: DragLinkData,
+ context: RenderContext
+ ): Path2D {
+ // Create LinkRenderData from drag data
+ // When dragging from input, swap the points/directions
+ const linkData: LinkRenderData = dragData.fromInput
+ ? {
+ id: 'dragging',
+ startPoint: dragData.dragPoint,
+ endPoint: dragData.fixedPoint,
+ startDirection:
+ dragData.dragDirection ||
+ this.getOppositeDirection(dragData.fixedDirection),
+ endDirection: dragData.fixedDirection,
+ color: dragData.color,
+ type: dragData.type,
+ disabled: dragData.disabled
+ }
+ : {
+ id: 'dragging',
+ startPoint: dragData.fixedPoint,
+ endPoint: dragData.dragPoint,
+ startDirection: dragData.fixedDirection,
+ endDirection:
+ dragData.dragDirection ||
+ this.getOppositeDirection(dragData.fixedDirection),
+ color: dragData.color,
+ type: dragData.type,
+ disabled: dragData.disabled
+ }
+
+ // Use standard link drawing
+ return this.drawLink(ctx, linkData, context)
+ }
+
+ /**
+ * Get the opposite direction (for drag preview)
+ */
+ private getOppositeDirection(direction: Direction): Direction {
+ switch (direction) {
+ case 'left':
+ return 'right'
+ case 'right':
+ return 'left'
+ case 'up':
+ return 'down'
+ case 'down':
+ return 'up'
+ }
+ }
+
+ /**
+ * Get the center point of a link (useful for labels, debugging)
+ */
+ getLinkCenter(link: LinkRenderData): Point {
+ // For now, simple midpoint
+ // Could be enhanced to find actual curve midpoint
+ return {
+ x: (link.startPoint.x + link.endPoint.x) / 2,
+ y: (link.startPoint.y + link.endPoint.y) / 2
+ }
+ }
+
+ /**
+ * Calculate and store the center point and angle of a link
+ * Mimics the original litegraph center point calculation
+ */
+ private calculateCenterPoint(
+ link: LinkRenderData,
+ context: RenderContext
+ ): void {
+ const { startPoint, endPoint, controlPoints } = link
+
+ if (
+ context.style.mode === 'spline' &&
+ controlPoints &&
+ controlPoints.length >= 2
+ ) {
+ // For spline mode, find point at t=0.5 on the bezier curve
+ const centerPos = this.findPointOnBezier(
+ 0.5,
+ startPoint,
+ controlPoints[0],
+ controlPoints[1],
+ endPoint
+ )
+ link.centerPos = centerPos
+
+ // Calculate angle for arrow marker (point slightly past center)
+ if (context.style.centerMarkerShape === 'arrow') {
+ const justPastCenter = this.findPointOnBezier(
+ 0.51,
+ startPoint,
+ controlPoints[0],
+ controlPoints[1],
+ endPoint
+ )
+ link.centerAngle = Math.atan2(
+ justPastCenter.y - centerPos.y,
+ justPastCenter.x - centerPos.x
+ )
+ }
+ } else if (context.style.mode === 'linear') {
+ // For linear mode, calculate midpoint between control points (matching original)
+ const l = 15 // Same offset as buildLinearPath
+ const innerA = { x: startPoint.x, y: startPoint.y }
+ const innerB = { x: endPoint.x, y: endPoint.y }
+
+ // Apply same directional offsets as buildLinearPath
+ switch (link.startDirection) {
+ case 'left':
+ innerA.x -= l
+ break
+ case 'right':
+ innerA.x += l
+ break
+ case 'up':
+ innerA.y -= l
+ break
+ case 'down':
+ innerA.y += l
+ break
+ }
+
+ switch (link.endDirection) {
+ case 'left':
+ innerB.x -= l
+ break
+ case 'right':
+ innerB.x += l
+ break
+ case 'up':
+ innerB.y -= l
+ break
+ case 'down':
+ innerB.y += l
+ break
+ }
+
+ link.centerPos = {
+ x: (innerA.x + innerB.x) * 0.5,
+ y: (innerA.y + innerB.y) * 0.5
+ }
+
+ if (context.style.centerMarkerShape === 'arrow') {
+ link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x)
+ }
+ } else if (context.style.mode === 'straight') {
+ // For straight mode, match original STRAIGHT_LINK center calculation
+ const l = 10 // Same offset as buildStraightPath
+ const innerA = { x: startPoint.x, y: startPoint.y }
+ const innerB = { x: endPoint.x, y: endPoint.y }
+
+ // Apply same directional offsets as buildStraightPath
+ switch (link.startDirection) {
+ case 'left':
+ innerA.x -= l
+ break
+ case 'right':
+ innerA.x += l
+ break
+ case 'up':
+ innerA.y -= l
+ break
+ case 'down':
+ innerA.y += l
+ break
+ }
+
+ switch (link.endDirection) {
+ case 'left':
+ innerB.x -= l
+ break
+ case 'right':
+ innerB.x += l
+ break
+ case 'up':
+ innerB.y -= l
+ break
+ case 'down':
+ innerB.y += l
+ break
+ }
+
+ // Calculate center using midX and average of innerA/innerB y positions
+ const midX = (innerA.x + innerB.x) * 0.5
+ link.centerPos = {
+ x: midX,
+ y: (innerA.y + innerB.y) * 0.5
+ }
+
+ if (context.style.centerMarkerShape === 'arrow') {
+ const diff = innerB.y - innerA.y
+ if (Math.abs(diff) < 4) {
+ link.centerAngle = 0
+ } else if (diff > 0) {
+ link.centerAngle = Math.PI * 0.5
+ } else {
+ link.centerAngle = -(Math.PI * 0.5)
+ }
+ }
+ } else {
+ // Fallback to simple midpoint
+ link.centerPos = this.getLinkCenter(link)
+ if (context.style.centerMarkerShape === 'arrow') {
+ link.centerAngle = Math.atan2(
+ endPoint.y - startPoint.y,
+ endPoint.x - startPoint.x
+ )
+ }
+ }
+ }
+
+ /**
+ * Draw the center marker on a link (for menu interaction)
+ * Matches the original litegraph center marker rendering
+ */
+ private drawCenterMarker(
+ ctx: CanvasRenderingContext2D,
+ link: LinkRenderData,
+ context: RenderContext,
+ color: string
+ ): void {
+ if (!link.centerPos) return
+
+ ctx.beginPath()
+
+ if (
+ context.style.centerMarkerShape === 'arrow' &&
+ link.centerAngle !== undefined
+ ) {
+ const transform = ctx.getTransform()
+ ctx.translate(link.centerPos.x, link.centerPos.y)
+ ctx.rotate(link.centerAngle)
+ // The math is off, but it currently looks better in chromium (from original)
+ ctx.moveTo(-3.2, -5)
+ ctx.lineTo(7, 0)
+ ctx.lineTo(-3.2, 5)
+ ctx.setTransform(transform)
+ } else {
+ // Default to circle
+ ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
+ }
+
+ // Apply disabled pattern or color
+ if (link.disabled && context.patterns?.disabled) {
+ const { fillStyle, globalAlpha } = ctx
+ ctx.fillStyle = context.patterns.disabled
+ ctx.globalAlpha = 0.75
+ ctx.fill()
+ ctx.globalAlpha = globalAlpha
+ ctx.fillStyle = fillStyle
+ } else {
+ ctx.fillStyle = color
+ ctx.fill()
+ }
+ }
+}
diff --git a/src/renderer/core/layout/constants.ts b/src/renderer/core/layout/constants.ts
new file mode 100644
index 0000000000..cc1de914ed
--- /dev/null
+++ b/src/renderer/core/layout/constants.ts
@@ -0,0 +1,50 @@
+/**
+ * Layout System Constants
+ *
+ * Centralized configuration values for the layout system.
+ * These values control spatial indexing, performance, and behavior.
+ */
+import { LayoutSource } from '@/renderer/core/layout/types'
+
+/**
+ * QuadTree configuration for spatial indexing
+ */
+export const QUADTREE_CONFIG = {
+ /** Default bounds for the QuadTree - covers a large canvas area */
+ DEFAULT_BOUNDS: {
+ x: -10000,
+ y: -10000,
+ width: 20000,
+ height: 20000
+ },
+ /** Maximum tree depth to prevent excessive subdivision */
+ MAX_DEPTH: 6,
+ /** Maximum items per node before subdivision */
+ MAX_ITEMS_PER_NODE: 4
+} as const
+
+/**
+ * Performance and optimization settings
+ */
+export const PERFORMANCE_CONFIG = {
+ /** RAF-based change detection interval (roughly 60fps) */
+ CHANGE_DETECTION_INTERVAL: 16,
+ /** Spatial query cache TTL in milliseconds */
+ SPATIAL_CACHE_TTL: 1000,
+ /** Maximum cache size for spatial queries */
+ SPATIAL_CACHE_MAX_SIZE: 100,
+ /** Batch update delay in milliseconds */
+ BATCH_UPDATE_DELAY: 4
+} as const
+
+/**
+ * Actor and source identifiers
+ */
+export const ACTOR_CONFIG = {
+ /** Prefix for auto-generated actor IDs */
+ USER_PREFIX: 'user-',
+ /** Length of random suffix for actor IDs */
+ ID_LENGTH: 9,
+ /** Default source when not specified */
+ DEFAULT_SOURCE: LayoutSource.External
+} as const
diff --git a/src/renderer/core/layout/operations/layoutMutations.ts b/src/renderer/core/layout/operations/layoutMutations.ts
new file mode 100644
index 0000000000..42ce2677c9
--- /dev/null
+++ b/src/renderer/core/layout/operations/layoutMutations.ts
@@ -0,0 +1,340 @@
+/**
+ * Layout Mutations - Simplified Direct Operations
+ *
+ * Provides a clean API for layout operations that are CRDT-ready.
+ * Operations are synchronous and applied directly to the store.
+ */
+import log from 'loglevel'
+
+import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import {
+ LayoutSource,
+ type LinkId,
+ type NodeLayout,
+ type Point,
+ type RerouteId,
+ type Size
+} from '@/renderer/core/layout/types'
+
+const logger = log.getLogger('LayoutMutations')
+
+export interface LayoutMutations {
+ // Single node operations (synchronous, CRDT-ready)
+ moveNode(nodeId: NodeId, position: Point): void
+ resizeNode(nodeId: NodeId, size: Size): void
+ setNodeZIndex(nodeId: NodeId, zIndex: number): void
+
+ // Node lifecycle operations
+ createNode(nodeId: NodeId, layout: Partial): void
+ deleteNode(nodeId: NodeId): void
+
+ // Link operations
+ createLink(
+ linkId: LinkId,
+ sourceNodeId: NodeId,
+ sourceSlot: number,
+ targetNodeId: NodeId,
+ targetSlot: number
+ ): void
+ deleteLink(linkId: LinkId): void
+
+ // Reroute operations
+ createReroute(
+ rerouteId: RerouteId,
+ position: Point,
+ parentId?: LinkId,
+ linkIds?: LinkId[]
+ ): void
+ deleteReroute(rerouteId: RerouteId): void
+ moveReroute(
+ rerouteId: RerouteId,
+ position: Point,
+ previousPosition: Point
+ ): void
+
+ // Stacking operations
+ bringNodeToFront(nodeId: NodeId): void
+
+ // Source tracking
+ setSource(source: LayoutSource): void
+ setActor(actor: string): void
+}
+
+/**
+ * Composable for accessing layout mutations with clean destructuring API
+ */
+export function useLayoutMutations(): LayoutMutations {
+ /**
+ * Set the current mutation source
+ */
+ const setSource = (source: LayoutSource): void => {
+ layoutStore.setSource(source)
+ }
+
+ /**
+ * Set the current actor (for CRDT)
+ */
+ const setActor = (actor: string): void => {
+ layoutStore.setActor(actor)
+ }
+
+ /**
+ * Move a node to a new position
+ */
+ const moveNode = (nodeId: NodeId, position: Point): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ position,
+ previousPosition: existing.position,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Resize a node
+ */
+ const resizeNode = (nodeId: NodeId, size: Size): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'resizeNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ size,
+ previousSize: existing.size,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Set node z-index
+ */
+ const setNodeZIndex = (nodeId: NodeId, zIndex: number): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'setNodeZIndex',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ zIndex,
+ previousZIndex: existing.zIndex,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Create a new node
+ */
+ const createNode = (nodeId: NodeId, layout: Partial): void => {
+ const normalizedNodeId = String(nodeId)
+ const fullLayout: NodeLayout = {
+ id: normalizedNodeId,
+ position: layout.position ?? { x: 0, y: 0 },
+ size: layout.size ?? { width: 200, height: 100 },
+ zIndex: layout.zIndex ?? 0,
+ visible: layout.visible ?? true,
+ bounds: {
+ x: layout.position?.x ?? 0,
+ y: layout.position?.y ?? 0,
+ width: layout.size?.width ?? 200,
+ height: layout.size?.height ?? 100
+ }
+ }
+
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ layout: fullLayout,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a node
+ */
+ const deleteNode = (nodeId: NodeId): void => {
+ const normalizedNodeId = String(nodeId)
+ const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
+ if (!existing) return
+
+ layoutStore.applyOperation({
+ type: 'deleteNode',
+ entity: 'node',
+ nodeId: normalizedNodeId,
+ previousLayout: existing,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Bring a node to the front (highest z-index)
+ */
+ const bringNodeToFront = (nodeId: NodeId): void => {
+ // Get all nodes to find the highest z-index
+ const allNodes = layoutStore.getAllNodes().value
+ let maxZIndex = 0
+
+ for (const [, layout] of allNodes) {
+ if (layout.zIndex > maxZIndex) {
+ maxZIndex = layout.zIndex
+ }
+ }
+
+ // Set this node's z-index to be one higher than the current max
+ setNodeZIndex(nodeId, maxZIndex + 1)
+ }
+
+ /**
+ * Create a new link
+ */
+ const createLink = (
+ linkId: LinkId,
+ sourceNodeId: NodeId,
+ sourceSlot: number,
+ targetNodeId: NodeId,
+ targetSlot: number
+ ): void => {
+ // Normalize node IDs to strings for layout store consistency
+ const normalizedSourceNodeId = String(sourceNodeId)
+ const normalizedTargetNodeId = String(targetNodeId)
+
+ logger.debug('Creating link:', {
+ linkId,
+ from: `${normalizedSourceNodeId}[${sourceSlot}]`,
+ to: `${normalizedTargetNodeId}[${targetSlot}]`
+ })
+ layoutStore.applyOperation({
+ type: 'createLink',
+ entity: 'link',
+ linkId,
+ sourceNodeId: normalizedSourceNodeId,
+ sourceSlot,
+ targetNodeId: normalizedTargetNodeId,
+ targetSlot,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a link
+ */
+ const deleteLink = (linkId: LinkId): void => {
+ logger.debug('Deleting link:', linkId)
+ layoutStore.applyOperation({
+ type: 'deleteLink',
+ entity: 'link',
+ linkId,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Create a new reroute
+ */
+ const createReroute = (
+ rerouteId: RerouteId,
+ position: Point,
+ parentId?: LinkId,
+ linkIds: LinkId[] = []
+ ): void => {
+ logger.debug('Creating reroute:', {
+ rerouteId,
+ position,
+ parentId,
+ linkCount: linkIds.length
+ })
+ layoutStore.applyOperation({
+ type: 'createReroute',
+ entity: 'reroute',
+ rerouteId,
+ position,
+ parentId,
+ linkIds,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Delete a reroute
+ */
+ const deleteReroute = (rerouteId: RerouteId): void => {
+ logger.debug('Deleting reroute:', rerouteId)
+ layoutStore.applyOperation({
+ type: 'deleteReroute',
+ entity: 'reroute',
+ rerouteId,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ /**
+ * Move a reroute
+ */
+ const moveReroute = (
+ rerouteId: RerouteId,
+ position: Point,
+ previousPosition: Point
+ ): void => {
+ logger.debug('Moving reroute:', {
+ rerouteId,
+ from: previousPosition,
+ to: position
+ })
+ layoutStore.applyOperation({
+ type: 'moveReroute',
+ entity: 'reroute',
+ rerouteId,
+ position,
+ previousPosition,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+ }
+
+ return {
+ setSource,
+ setActor,
+ moveNode,
+ resizeNode,
+ setNodeZIndex,
+ createNode,
+ deleteNode,
+ bringNodeToFront,
+ createLink,
+ deleteLink,
+ createReroute,
+ deleteReroute,
+ moveReroute
+ }
+}
diff --git a/src/renderer/core/layout/slots/register.ts b/src/renderer/core/layout/slots/register.ts
new file mode 100644
index 0000000000..5965b885bc
--- /dev/null
+++ b/src/renderer/core/layout/slots/register.ts
@@ -0,0 +1,75 @@
+/**
+ * Slot Registration
+ *
+ * Handles registration of slot layouts with the layout store for hit testing.
+ * This module manages the state mutation side of slot layout management,
+ * while pure calculations are handled separately in SlotCalculations.ts.
+ */
+import type { Point } from '@/lib/litegraph/src/interfaces'
+import { LiteGraph } from '@/lib/litegraph/src/litegraph'
+import {
+ type SlotPositionContext,
+ calculateInputSlotPos,
+ calculateOutputSlotPos
+} from '@/renderer/core/canvas/litegraph/slotCalculations'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import type { SlotLayout } from '@/renderer/core/layout/types'
+
+import { getSlotKey } from './slotIdentifier'
+
+/**
+ * Register slot layout with the layout store for hit testing
+ * @param nodeId The node ID
+ * @param slotIndex The slot index
+ * @param isInput Whether this is an input slot
+ * @param position The slot position in graph coordinates
+ */
+export function registerSlotLayout(
+ nodeId: string,
+ slotIndex: number,
+ isInput: boolean,
+ position: Point
+): void {
+ const slotKey = getSlotKey(nodeId, slotIndex, isInput)
+
+ // Calculate bounds for the slot using LiteGraph's standard slot height
+ const slotSize = LiteGraph.NODE_SLOT_HEIGHT
+ const halfSize = slotSize / 2
+
+ const slotLayout: SlotLayout = {
+ nodeId,
+ index: slotIndex,
+ type: isInput ? 'input' : 'output',
+ position: { x: position[0], y: position[1] },
+ bounds: {
+ x: position[0] - halfSize,
+ y: position[1] - halfSize,
+ width: slotSize,
+ height: slotSize
+ }
+ }
+
+ layoutStore.updateSlotLayout(slotKey, slotLayout)
+}
+
+/**
+ * Register all slots for a node
+ * @param nodeId The node ID
+ * @param context The slot position context
+ */
+export function registerNodeSlots(
+ nodeId: string,
+ context: SlotPositionContext
+): void {
+ // Register input slots
+ context.inputs.forEach((_, index) => {
+ const position = calculateInputSlotPos(context, index)
+ registerSlotLayout(nodeId, index, true, position)
+ })
+
+ // Register output slots
+ context.outputs.forEach((_, index) => {
+ const position = calculateOutputSlotPos(context, index)
+ registerSlotLayout(nodeId, index, false, position)
+ })
+}
diff --git a/src/renderer/core/layout/slots/slotIdentifier.ts b/src/renderer/core/layout/slots/slotIdentifier.ts
new file mode 100644
index 0000000000..df1f64fc27
--- /dev/null
+++ b/src/renderer/core/layout/slots/slotIdentifier.ts
@@ -0,0 +1,40 @@
+/**
+ * Slot identifier utilities for consistent slot key generation and parsing
+ *
+ * Provides a centralized interface for slot identification across the layout system
+ *
+ * @TODO Replace this concatenated string with root cause fix
+ */
+
+export interface SlotIdentifier {
+ nodeId: string
+ index: number
+ isInput: boolean
+}
+
+/**
+ * Generate a unique key for a slot
+ * Format: "{nodeId}-{in|out}-{index}"
+ */
+export function getSlotKey(identifier: SlotIdentifier): string
+export function getSlotKey(
+ nodeId: string,
+ index: number,
+ isInput: boolean
+): string
+export function getSlotKey(
+ nodeIdOrIdentifier: string | SlotIdentifier,
+ index?: number,
+ isInput?: boolean
+): string {
+ if (typeof nodeIdOrIdentifier === 'object') {
+ const { nodeId, index, isInput } = nodeIdOrIdentifier
+ return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
+ }
+
+ if (index === undefined || isInput === undefined) {
+ throw new Error('Missing required parameters for slot key generation')
+ }
+
+ return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
+}
diff --git a/src/renderer/core/layout/slots/useDomSlotRegistration.ts b/src/renderer/core/layout/slots/useDomSlotRegistration.ts
new file mode 100644
index 0000000000..94a1f09e59
--- /dev/null
+++ b/src/renderer/core/layout/slots/useDomSlotRegistration.ts
@@ -0,0 +1,229 @@
+/**
+ * DOM-based slot registration with performance optimization
+ *
+ * Measures the actual DOM position of a Vue slot connector and registers it
+ * into the LayoutStore so hit-testing and link rendering use the true position.
+ *
+ * Performance strategy:
+ * - Cache slot offset relative to node (avoids DOM reads during drag)
+ * - No measurements during pan/zoom (camera transforms don't change canvas coords)
+ * - Batch DOM reads via requestAnimationFrame
+ * - Only remeasure on structural changes (resize, collapse, LOD)
+ */
+import {
+ type Ref,
+ type WatchStopHandle,
+ nextTick,
+ onMounted,
+ onUnmounted,
+ ref,
+ watch
+} from 'vue'
+
+import { LiteGraph } from '@/lib/litegraph/src/litegraph'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
+
+import { getSlotKey } from './slotIdentifier'
+
+export type TransformState = {
+ screenToCanvas: (p: LayoutPoint) => LayoutPoint
+}
+
+// Shared RAF queue for batching measurements
+const measureQueue = new Set<() => void>()
+let rafId: number | null = null
+// Track mounted components to prevent execution on unmounted ones
+const mountedComponents = new WeakSet()
+
+function scheduleMeasurement(fn: () => void) {
+ measureQueue.add(fn)
+ if (rafId === null) {
+ rafId = requestAnimationFrame(() => {
+ rafId = null
+ const batch = Array.from(measureQueue)
+ measureQueue.clear()
+ batch.forEach((measure) => measure())
+ })
+ }
+}
+
+const cleanupFunctions = new WeakMap<
+ Ref,
+ {
+ stopWatcher?: WatchStopHandle
+ handleResize?: () => void
+ }
+>()
+
+interface SlotRegistrationOptions {
+ nodeId: string
+ slotIndex: number
+ isInput: boolean
+ element: Ref
+ transform?: TransformState
+}
+
+export function useDomSlotRegistration(options: SlotRegistrationOptions) {
+ const { nodeId, slotIndex, isInput, element: elRef, transform } = options
+
+ // Early return if no nodeId
+ if (!nodeId || nodeId === '') {
+ return {
+ remeasure: () => {}
+ }
+ }
+ const slotKey = getSlotKey(nodeId, slotIndex, isInput)
+ // Track if this component is mounted
+ const componentToken = {}
+
+ // Cached offset from node position (avoids DOM reads during drag)
+ const cachedOffset = ref(null)
+ const lastMeasuredBounds = ref(null)
+
+ // Measure DOM and cache offset (expensive, minimize calls)
+ const measureAndCacheOffset = () => {
+ // Skip if component was unmounted
+ if (!mountedComponents.has(componentToken)) return
+
+ const el = elRef.value
+ if (!el || !transform?.screenToCanvas) return
+
+ const rect = el.getBoundingClientRect()
+
+ // Skip if bounds haven't changed significantly (within 0.5px)
+ if (lastMeasuredBounds.value) {
+ const prev = lastMeasuredBounds.value
+ if (
+ Math.abs(rect.left - prev.left) < 0.5 &&
+ Math.abs(rect.top - prev.top) < 0.5 &&
+ Math.abs(rect.width - prev.width) < 0.5 &&
+ Math.abs(rect.height - prev.height) < 0.5
+ ) {
+ return // No significant change - skip update
+ }
+ }
+
+ lastMeasuredBounds.value = rect
+
+ // Center of the visual connector (dot) in screen coords
+ const centerScreen = {
+ x: rect.left + rect.width / 2,
+ y: rect.top + rect.height / 2
+ }
+ const centerCanvas = transform.screenToCanvas(centerScreen)
+
+ // Cache offset from node position for fast updates during drag
+ const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
+ if (nodeLayout) {
+ cachedOffset.value = {
+ x: centerCanvas.x - nodeLayout.position.x,
+ y: centerCanvas.y - nodeLayout.position.y
+ }
+ }
+
+ updateSlotPosition(centerCanvas)
+ }
+
+ // Fast update using cached offset (no DOM read)
+ const updateFromCachedOffset = () => {
+ if (!cachedOffset.value) {
+ // No cached offset yet, need to measure
+ scheduleMeasurement(measureAndCacheOffset)
+ return
+ }
+
+ const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!nodeLayout) {
+ return
+ }
+
+ // Calculate absolute position from node position + cached offset
+ const centerCanvas = {
+ x: nodeLayout.position.x + cachedOffset.value.x,
+ y: nodeLayout.position.y + cachedOffset.value.y
+ }
+
+ updateSlotPosition(centerCanvas)
+ }
+
+ // Update slot position in layout store
+ const updateSlotPosition = (centerCanvas: LayoutPoint) => {
+ const size = LiteGraph.NODE_SLOT_HEIGHT
+ const half = size / 2
+
+ layoutStore.updateSlotLayout(slotKey, {
+ nodeId,
+ index: slotIndex,
+ type: isInput ? 'input' : 'output',
+ position: { x: centerCanvas.x, y: centerCanvas.y },
+ bounds: {
+ x: centerCanvas.x - half,
+ y: centerCanvas.y - half,
+ width: size,
+ height: size
+ }
+ })
+ }
+
+ onMounted(async () => {
+ // Mark component as mounted
+ mountedComponents.add(componentToken)
+
+ // Initial measure after mount
+ await nextTick()
+ measureAndCacheOffset()
+
+ // Subscribe to node position changes for fast cached updates
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+
+ const stopWatcher = watch(
+ nodeRef,
+ (newLayout) => {
+ if (newLayout) {
+ // Node moved/resized - update using cached offset
+ updateFromCachedOffset()
+ }
+ },
+ { immediate: false }
+ )
+
+ // Store cleanup functions without type assertions
+ const cleanup = cleanupFunctions.get(elRef) || {}
+ cleanup.stopWatcher = stopWatcher
+
+ // Window resize - remeasure as viewport changed
+ const handleResize = () => {
+ scheduleMeasurement(measureAndCacheOffset)
+ }
+ window.addEventListener('resize', handleResize, { passive: true })
+ cleanup.handleResize = handleResize
+ cleanupFunctions.set(elRef, cleanup)
+ })
+
+ onUnmounted(() => {
+ // Mark component as unmounted
+ mountedComponents.delete(componentToken)
+
+ // Clean up watchers and listeners
+ const cleanup = cleanupFunctions.get(elRef)
+ if (cleanup) {
+ if (cleanup.stopWatcher) cleanup.stopWatcher()
+ if (cleanup.handleResize) {
+ window.removeEventListener('resize', cleanup.handleResize)
+ }
+ cleanupFunctions.delete(elRef)
+ }
+
+ // Remove from layout store
+ layoutStore.deleteSlotLayout(slotKey)
+
+ // Remove from measurement queue if pending
+ measureQueue.delete(measureAndCacheOffset)
+ })
+
+ return {
+ // Expose for forced remeasure on structural changes
+ remeasure: () => scheduleMeasurement(measureAndCacheOffset)
+ }
+}
diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts
new file mode 100644
index 0000000000..5be50702b2
--- /dev/null
+++ b/src/renderer/core/layout/store/layoutStore.ts
@@ -0,0 +1,1356 @@
+/**
+ * Layout Store - Single Source of Truth
+ *
+ * Uses Yjs for efficient local state management and future collaboration.
+ * CRDT ensures conflict-free operations for both single and multi-user scenarios.
+ */
+import log from 'loglevel'
+import { type ComputedRef, type Ref, computed, customRef } from 'vue'
+import * as Y from 'yjs'
+
+import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
+import type {
+ CreateLinkOperation,
+ CreateNodeOperation,
+ CreateRerouteOperation,
+ DeleteLinkOperation,
+ DeleteNodeOperation,
+ DeleteRerouteOperation,
+ LayoutOperation,
+ MoveNodeOperation,
+ MoveRerouteOperation,
+ ResizeNodeOperation,
+ SetNodeZIndexOperation
+} from '@/renderer/core/layout/types'
+import {
+ type Bounds,
+ type LayoutChange,
+ LayoutSource,
+ type LayoutStore,
+ type LinkId,
+ type LinkLayout,
+ type LinkSegmentLayout,
+ type NodeId,
+ type NodeLayout,
+ type Point,
+ type RerouteId,
+ type RerouteLayout,
+ type SlotLayout
+} from '@/renderer/core/layout/types'
+import { SpatialIndexManager } from '@/renderer/core/spatial/SpatialIndex'
+
+type YEventChange = {
+ action: 'add' | 'update' | 'delete'
+ oldValue: unknown
+}
+
+const logger = log.getLogger('LayoutStore')
+
+// Constants
+const REROUTE_RADIUS = 8
+
+class LayoutStoreImpl implements LayoutStore {
+ // Yjs document and shared data structures
+ private ydoc = new Y.Doc()
+ private ynodes: Y.Map> // Maps nodeId -> Y.Map containing NodeLayout data
+ private ylinks: Y.Map> // Maps linkId -> Y.Map containing link data
+ private yreroutes: Y.Map> // Maps rerouteId -> Y.Map containing reroute data
+ private yoperations: Y.Array // Operation log
+
+ // Vue reactivity layer
+ private version = 0
+ private currentSource: LayoutSource =
+ ACTOR_CONFIG.DEFAULT_SOURCE as LayoutSource
+ private currentActor = `${ACTOR_CONFIG.USER_PREFIX}${Math.random()
+ .toString(36)
+ .substring(2, 2 + ACTOR_CONFIG.ID_LENGTH)}`
+
+ // Change listeners
+ private changeListeners = new Set<(change: LayoutChange) => void>()
+
+ // CustomRef cache and trigger functions
+ private nodeRefs = new Map>()
+ private nodeTriggers = new Map void>()
+
+ // New data structures for hit testing
+ private linkLayouts = new Map()
+ private linkSegmentLayouts = new Map() // Internal string key: ${linkId}:${rerouteId ?? 'final'}
+ private slotLayouts = new Map()
+ private rerouteLayouts = new Map()
+
+ // Spatial index managers
+ private spatialIndex: SpatialIndexManager // For nodes
+ private linkSegmentSpatialIndex: SpatialIndexManager // For link segments (single index for all link geometry)
+ private slotSpatialIndex: SpatialIndexManager // For slots
+ private rerouteSpatialIndex: SpatialIndexManager // For reroutes
+
+ constructor() {
+ // Initialize Yjs data structures
+ this.ynodes = this.ydoc.getMap('nodes')
+ this.ylinks = this.ydoc.getMap('links')
+ this.yreroutes = this.ydoc.getMap('reroutes')
+ this.yoperations = this.ydoc.getArray('operations')
+
+ // Initialize spatial index managers
+ this.spatialIndex = new SpatialIndexManager()
+ this.linkSegmentSpatialIndex = new SpatialIndexManager() // Single index for all link geometry
+ this.slotSpatialIndex = new SpatialIndexManager()
+ this.rerouteSpatialIndex = new SpatialIndexManager()
+
+ // Listen for Yjs changes and trigger Vue reactivity
+ this.ynodes.observe((event: Y.YMapEvent>) => {
+ this.version++
+
+ // Trigger all affected node refs
+ event.changes.keys.forEach((_change: YEventChange, key: string) => {
+ const trigger = this.nodeTriggers.get(key)
+ if (trigger) {
+ trigger()
+ }
+ })
+ })
+
+ // Listen for link changes and update spatial indexes
+ this.ylinks.observe((event: Y.YMapEvent>) => {
+ this.version++
+ event.changes.keys.forEach((change, linkIdStr) => {
+ this.handleLinkChange(change, linkIdStr)
+ })
+ })
+
+ // Listen for reroute changes and update spatial indexes
+ this.yreroutes.observe((event: Y.YMapEvent>) => {
+ this.version++
+ event.changes.keys.forEach((change, rerouteIdStr) => {
+ this.handleRerouteChange(change, rerouteIdStr)
+ })
+ })
+ }
+
+ /**
+ * Get or create a customRef for a node layout
+ */
+ getNodeLayoutRef(nodeId: NodeId): Ref {
+ let nodeRef = this.nodeRefs.get(nodeId)
+
+ if (!nodeRef) {
+ nodeRef = customRef((track, trigger) => {
+ // Store the trigger so we can call it when Yjs changes
+ this.nodeTriggers.set(nodeId, trigger)
+
+ return {
+ get: () => {
+ track()
+ const ynode = this.ynodes.get(nodeId)
+ const layout = ynode ? this.yNodeToLayout(ynode) : null
+ return layout
+ },
+ set: (newLayout: NodeLayout | null) => {
+ if (newLayout === null) {
+ // Delete operation
+ const existing = this.ynodes.get(nodeId)
+ if (existing) {
+ this.applyOperation({
+ type: 'deleteNode',
+ entity: 'node',
+ nodeId,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor,
+ previousLayout: this.yNodeToLayout(existing)
+ })
+ }
+ } else {
+ // Update operation - detect what changed
+ const existing = this.ynodes.get(nodeId)
+ if (!existing) {
+ // Create operation
+ this.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout: newLayout,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ } else {
+ const existingLayout = this.yNodeToLayout(existing)
+
+ // Check what properties changed
+ if (
+ existingLayout.position.x !== newLayout.position.x ||
+ existingLayout.position.y !== newLayout.position.y
+ ) {
+ this.applyOperation({
+ type: 'moveNode',
+ entity: 'node',
+ nodeId,
+ position: newLayout.position,
+ previousPosition: existingLayout.position,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ if (
+ existingLayout.size.width !== newLayout.size.width ||
+ existingLayout.size.height !== newLayout.size.height
+ ) {
+ this.applyOperation({
+ type: 'resizeNode',
+ entity: 'node',
+ nodeId,
+ size: newLayout.size,
+ previousSize: existingLayout.size,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ if (existingLayout.zIndex !== newLayout.zIndex) {
+ this.applyOperation({
+ type: 'setNodeZIndex',
+ entity: 'node',
+ nodeId,
+ zIndex: newLayout.zIndex,
+ previousZIndex: existingLayout.zIndex,
+ timestamp: Date.now(),
+ source: this.currentSource,
+ actor: this.currentActor
+ })
+ }
+ }
+ }
+ trigger()
+ }
+ }
+ })
+
+ this.nodeRefs.set(nodeId, nodeRef)
+ }
+
+ return nodeRef
+ }
+
+ /**
+ * Get nodes within bounds (reactive)
+ */
+ getNodesInBounds(bounds: Bounds): ComputedRef {
+ return computed(() => {
+ // Touch version for reactivity
+ void this.version
+
+ const result: NodeId[] = []
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout && this.boundsIntersect(layout.bounds, bounds)) {
+ result.push(nodeId)
+ }
+ }
+ }
+ return result
+ })
+ }
+
+ /**
+ * Get all nodes as a reactive map
+ */
+ getAllNodes(): ComputedRef> {
+ return computed(() => {
+ // Touch version for reactivity
+ void this.version
+
+ const result = new Map()
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout) {
+ result.set(nodeId, layout)
+ }
+ }
+ }
+ return result
+ })
+ }
+
+ /**
+ * Get current version for change detection
+ */
+ getVersion(): ComputedRef {
+ return computed(() => this.version)
+ }
+
+ /**
+ * Query node at point (non-reactive for performance)
+ */
+ queryNodeAtPoint(point: Point): NodeId | null {
+ const nodes: Array<[NodeId, NodeLayout]> = []
+
+ for (const [nodeId] of this.ynodes) {
+ const ynode = this.ynodes.get(nodeId)
+ if (ynode) {
+ const layout = this.yNodeToLayout(ynode)
+ if (layout) {
+ nodes.push([nodeId, layout])
+ }
+ }
+ }
+
+ // Sort by zIndex (top to bottom)
+ nodes.sort(([, a], [, b]) => b.zIndex - a.zIndex)
+
+ for (const [nodeId, layout] of nodes) {
+ if (this.pointInBounds(point, layout.bounds)) {
+ return nodeId
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Query nodes in bounds (non-reactive for performance)
+ */
+ queryNodesInBounds(bounds: Bounds): NodeId[] {
+ return this.spatialIndex.query(bounds)
+ }
+
+ /**
+ * Update link layout data (for geometry/debug, no separate spatial index)
+ */
+ updateLinkLayout(linkId: LinkId, layout: LinkLayout): void {
+ const existing = this.linkLayouts.get(linkId)
+
+ // Short-circuit if bounds and centerPos unchanged
+ if (
+ existing &&
+ existing.bounds.x === layout.bounds.x &&
+ existing.bounds.y === layout.bounds.y &&
+ existing.bounds.width === layout.bounds.width &&
+ existing.bounds.height === layout.bounds.height &&
+ existing.centerPos.x === layout.centerPos.x &&
+ existing.centerPos.y === layout.centerPos.y
+ ) {
+ // Only update path if provided (for hit detection)
+ if (layout.path) {
+ existing.path = layout.path
+ }
+ return
+ }
+
+ this.linkLayouts.set(linkId, layout)
+ }
+
+ /**
+ * Delete link layout data
+ */
+ deleteLinkLayout(linkId: LinkId): void {
+ const deleted = this.linkLayouts.delete(linkId)
+ if (deleted) {
+ // Clean up any segment layouts for this link
+ const keysToDelete: string[] = []
+ for (const [key] of this.linkSegmentLayouts) {
+ if (key.startsWith(`${linkId}:`)) {
+ keysToDelete.push(key)
+ }
+ }
+ for (const key of keysToDelete) {
+ this.linkSegmentLayouts.delete(key)
+ this.linkSegmentSpatialIndex.remove(key)
+ }
+ }
+ }
+
+ /**
+ * Update slot layout data
+ */
+ updateSlotLayout(key: string, layout: SlotLayout): void {
+ const existing = this.slotLayouts.get(key)
+
+ if (!existing) {
+ logger.debug('Adding slot:', {
+ nodeId: layout.nodeId,
+ type: layout.type,
+ index: layout.index,
+ bounds: layout.bounds
+ })
+ }
+
+ if (existing) {
+ // Update spatial index
+ this.slotSpatialIndex.update(key, layout.bounds)
+ } else {
+ // Insert into spatial index
+ this.slotSpatialIndex.insert(key, layout.bounds)
+ }
+
+ this.slotLayouts.set(key, layout)
+ }
+
+ /**
+ * Delete slot layout data
+ */
+ deleteSlotLayout(key: string): void {
+ const deleted = this.slotLayouts.delete(key)
+ if (deleted) {
+ // Remove from spatial index
+ this.slotSpatialIndex.remove(key)
+ }
+ }
+
+ /**
+ * Delete all slot layouts for a node
+ */
+ deleteNodeSlotLayouts(nodeId: NodeId): void {
+ const keysToDelete: string[] = []
+ for (const [key, layout] of this.slotLayouts) {
+ if (layout.nodeId === nodeId) {
+ keysToDelete.push(key)
+ }
+ }
+ for (const key of keysToDelete) {
+ this.slotLayouts.delete(key)
+ // Remove from spatial index
+ this.slotSpatialIndex.remove(key)
+ }
+ }
+
+ /**
+ * Update reroute layout data
+ */
+ updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void {
+ const existing = this.rerouteLayouts.get(rerouteId)
+
+ if (!existing) {
+ logger.debug('Adding reroute layout:', {
+ rerouteId,
+ position: layout.position,
+ bounds: layout.bounds
+ })
+ }
+
+ if (existing) {
+ // Update spatial index
+ this.rerouteSpatialIndex.update(String(rerouteId), layout.bounds) // Spatial index uses strings
+ } else {
+ // Insert into spatial index
+ this.rerouteSpatialIndex.insert(String(rerouteId), layout.bounds) // Spatial index uses strings
+ }
+
+ this.rerouteLayouts.set(rerouteId, layout)
+ }
+
+ /**
+ * Delete reroute layout data
+ */
+ deleteRerouteLayout(rerouteId: RerouteId): void {
+ const deleted = this.rerouteLayouts.delete(rerouteId)
+ if (deleted) {
+ // Remove from spatial index
+ this.rerouteSpatialIndex.remove(String(rerouteId)) // Spatial index uses strings
+ }
+ }
+
+ /**
+ * Get link layout data
+ */
+ getLinkLayout(linkId: LinkId): LinkLayout | null {
+ return this.linkLayouts.get(linkId) || null
+ }
+
+ /**
+ * Get slot layout data
+ */
+ getSlotLayout(key: string): SlotLayout | null {
+ return this.slotLayouts.get(key) || null
+ }
+
+ /**
+ * Get reroute layout data
+ */
+ getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null {
+ return this.rerouteLayouts.get(rerouteId) || null
+ }
+
+ /**
+ * Helper to create internal key for link segment
+ */
+ private makeLinkSegmentKey(
+ linkId: LinkId,
+ rerouteId: RerouteId | null
+ ): string {
+ return `${linkId}:${rerouteId ?? 'final'}`
+ }
+
+ /**
+ * Update link segment layout data
+ */
+ updateLinkSegmentLayout(
+ linkId: LinkId,
+ rerouteId: RerouteId | null,
+ layout: Omit
+ ): void {
+ const key = this.makeLinkSegmentKey(linkId, rerouteId)
+ const existing = this.linkSegmentLayouts.get(key)
+
+ // Short-circuit if bounds and centerPos unchanged (prevents spatial index churn)
+ if (
+ existing &&
+ existing.bounds.x === layout.bounds.x &&
+ existing.bounds.y === layout.bounds.y &&
+ existing.bounds.width === layout.bounds.width &&
+ existing.bounds.height === layout.bounds.height &&
+ existing.centerPos.x === layout.centerPos.x &&
+ existing.centerPos.y === layout.centerPos.y
+ ) {
+ // Only update path if provided (for hit detection)
+ if (layout.path) {
+ existing.path = layout.path
+ }
+ return
+ }
+
+ const fullLayout: LinkSegmentLayout = {
+ ...layout,
+ linkId,
+ rerouteId
+ }
+
+ if (!existing) {
+ logger.debug('Adding link segment:', {
+ linkId,
+ rerouteId,
+ bounds: layout.bounds,
+ hasPath: !!layout.path
+ })
+ }
+
+ if (existing) {
+ // Update spatial index
+ this.linkSegmentSpatialIndex.update(key, layout.bounds)
+ } else {
+ // Insert into spatial index
+ this.linkSegmentSpatialIndex.insert(key, layout.bounds)
+ }
+
+ this.linkSegmentLayouts.set(key, fullLayout)
+ }
+
+ /**
+ * Delete link segment layout data
+ */
+ deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void {
+ const key = this.makeLinkSegmentKey(linkId, rerouteId)
+ const deleted = this.linkSegmentLayouts.delete(key)
+ if (deleted) {
+ // Remove from spatial index
+ this.linkSegmentSpatialIndex.remove(key)
+ }
+ }
+
+ /**
+ * Query link segment at point (returns structured data)
+ */
+ queryLinkSegmentAtPoint(
+ point: Point,
+ ctx?: CanvasRenderingContext2D
+ ): { linkId: LinkId; rerouteId: RerouteId | null } | null {
+ // Determine tolerance from current canvas state (if available)
+ // - Use the caller-provided ctx.lineWidth (LGraphCanvas sets this to connections_width + padding)
+ // - Fall back to a sensible default when ctx is not provided
+ const hitWidth = ctx?.lineWidth ?? 10
+ const halfSize = Math.max(10, hitWidth) // keep a minimum window for spatial index
+
+ // Use spatial index to get candidate segments
+ const searchArea = {
+ x: point.x - halfSize,
+ y: point.y - halfSize,
+ width: halfSize * 2,
+ height: halfSize * 2
+ }
+ const candidateKeys = this.linkSegmentSpatialIndex.query(searchArea)
+
+ if (candidateKeys.length > 0) {
+ logger.debug('Checking link segments at point:', {
+ point,
+ candidateCount: candidateKeys.length,
+ tolerance: hitWidth
+ })
+ }
+
+ // Precise hit test only on candidates
+ for (const key of candidateKeys) {
+ const segmentLayout = this.linkSegmentLayouts.get(key)
+ if (!segmentLayout) continue
+
+ if (ctx && segmentLayout.path) {
+ // Match LiteGraph behavior: hit test uses device pixel ratio for coordinates
+ const dpi =
+ (typeof window !== 'undefined' && window?.devicePixelRatio) || 1
+ const hit = ctx.isPointInStroke(
+ segmentLayout.path,
+ point.x * dpi,
+ point.y * dpi
+ )
+
+ if (hit) {
+ logger.debug('Link segment hit:', {
+ linkId: segmentLayout.linkId,
+ rerouteId: segmentLayout.rerouteId,
+ point
+ })
+ return {
+ linkId: segmentLayout.linkId,
+ rerouteId: segmentLayout.rerouteId
+ }
+ }
+ } else if (this.pointInBounds(point, segmentLayout.bounds)) {
+ // Fallback to bounding box test
+ return {
+ linkId: segmentLayout.linkId,
+ rerouteId: segmentLayout.rerouteId
+ }
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Query link at point (derived from segment query)
+ */
+ queryLinkAtPoint(
+ point: Point,
+ ctx?: CanvasRenderingContext2D
+ ): LinkId | null {
+ // Invoke segment query and return just the linkId
+ const segment = this.queryLinkSegmentAtPoint(point, ctx)
+ return segment ? segment.linkId : null
+ }
+
+ /**
+ * Query slot at point
+ */
+ querySlotAtPoint(point: Point): SlotLayout | null {
+ // Use spatial index to get candidate slots
+ const searchArea = {
+ x: point.x - 10, // Tolerance for slot size
+ y: point.y - 10,
+ width: 20,
+ height: 20
+ }
+ const candidateSlotKeys = this.slotSpatialIndex.query(searchArea)
+
+ // Check precise bounds for candidates
+ for (const key of candidateSlotKeys) {
+ const slotLayout = this.slotLayouts.get(key)
+ if (slotLayout && this.pointInBounds(point, slotLayout.bounds)) {
+ return slotLayout
+ }
+ }
+ return null
+ }
+
+ /**
+ * Query reroute at point
+ */
+ queryRerouteAtPoint(point: Point): RerouteLayout | null {
+ // Use spatial index to get candidate reroutes
+ const maxRadius = 20 // Maximum expected reroute radius
+ const searchArea = {
+ x: point.x - maxRadius,
+ y: point.y - maxRadius,
+ width: maxRadius * 2,
+ height: maxRadius * 2
+ }
+ const candidateRerouteKeys = this.rerouteSpatialIndex.query(searchArea)
+
+ if (candidateRerouteKeys.length > 0) {
+ logger.debug('Checking reroutes at point:', {
+ point,
+ candidateCount: candidateRerouteKeys.length
+ })
+ }
+
+ // Check precise distance for candidates
+ for (const rerouteKey of candidateRerouteKeys) {
+ const rerouteId = Number(rerouteKey) as RerouteId // Convert string key back to numeric
+ const rerouteLayout = this.rerouteLayouts.get(rerouteId)
+ if (rerouteLayout) {
+ const dx = point.x - rerouteLayout.position.x
+ const dy = point.y - rerouteLayout.position.y
+ const distance = Math.sqrt(dx * dx + dy * dy)
+
+ if (distance <= rerouteLayout.radius) {
+ logger.debug('Reroute hit:', {
+ rerouteId: rerouteLayout.id,
+ position: rerouteLayout.position,
+ distance
+ })
+ return rerouteLayout
+ }
+ }
+ }
+ return null
+ }
+
+ /**
+ * Query all items in bounds
+ */
+ queryItemsInBounds(bounds: Bounds): {
+ nodes: NodeId[]
+ links: LinkId[]
+ slots: string[]
+ reroutes: RerouteId[]
+ } {
+ // Query segments and union their linkIds
+ const segmentKeys = this.linkSegmentSpatialIndex.query(bounds)
+ const linkIds = new Set()
+ for (const key of segmentKeys) {
+ const segment = this.linkSegmentLayouts.get(key)
+ if (segment) {
+ linkIds.add(segment.linkId)
+ }
+ }
+
+ return {
+ nodes: this.queryNodesInBounds(bounds),
+ links: Array.from(linkIds),
+ slots: this.slotSpatialIndex.query(bounds),
+ reroutes: this.rerouteSpatialIndex
+ .query(bounds)
+ .map((key) => Number(key) as RerouteId) // Convert string keys to numeric
+ }
+ }
+
+ /**
+ * Apply a layout operation using Yjs transactions
+ */
+ applyOperation(operation: LayoutOperation): void {
+ // Create change object outside transaction so we can use it after
+ const change: LayoutChange = {
+ type: 'update',
+ nodeIds: [],
+ timestamp: operation.timestamp,
+ source: operation.source,
+ operation
+ }
+
+ // Use Yjs transaction for atomic updates
+ this.ydoc.transact(() => {
+ // Add operation to log
+ this.yoperations.push([operation])
+
+ // Apply the operation
+ this.applyOperationInTransaction(operation, change)
+ }, this.currentActor)
+
+ // Post-transaction updates
+ this.finalizeOperation(change)
+ }
+
+ /**
+ * Apply operation within a transaction
+ */
+ private applyOperationInTransaction(
+ operation: LayoutOperation,
+ change: LayoutChange
+ ): void {
+ switch (operation.type) {
+ case 'moveNode':
+ this.handleMoveNode(operation as MoveNodeOperation, change)
+ break
+ case 'resizeNode':
+ this.handleResizeNode(operation as ResizeNodeOperation, change)
+ break
+ case 'setNodeZIndex':
+ this.handleSetNodeZIndex(operation as SetNodeZIndexOperation, change)
+ break
+ case 'createNode':
+ this.handleCreateNode(operation as CreateNodeOperation, change)
+ break
+ case 'deleteNode':
+ this.handleDeleteNode(operation as DeleteNodeOperation, change)
+ break
+ case 'createLink':
+ this.handleCreateLink(operation as CreateLinkOperation, change)
+ break
+ case 'deleteLink':
+ this.handleDeleteLink(operation as DeleteLinkOperation, change)
+ break
+ case 'createReroute':
+ this.handleCreateReroute(operation as CreateRerouteOperation, change)
+ break
+ case 'deleteReroute':
+ this.handleDeleteReroute(operation as DeleteRerouteOperation, change)
+ break
+ case 'moveReroute':
+ this.handleMoveReroute(operation as MoveRerouteOperation, change)
+ break
+ }
+ }
+
+ /**
+ * Finalize operation after transaction
+ */
+ private finalizeOperation(change: LayoutChange): void {
+ // Update version
+ this.version++
+
+ // Manually trigger affected node refs after transaction
+ // This is needed because Yjs observers don't fire for property changes
+ change.nodeIds.forEach((nodeId) => {
+ const trigger = this.nodeTriggers.get(nodeId)
+ if (trigger) {
+ trigger()
+ }
+ })
+
+ // Notify listeners (after transaction completes)
+ setTimeout(() => this.notifyChange(change), 0)
+ }
+
+ /**
+ * Subscribe to layout changes
+ */
+ onChange(callback: (change: LayoutChange) => void): () => void {
+ this.changeListeners.add(callback)
+ return () => this.changeListeners.delete(callback)
+ }
+
+ /**
+ * Set the current operation source
+ */
+ setSource(source: LayoutSource): void {
+ this.currentSource = source
+ }
+
+ /**
+ * Set the current actor (for CRDT)
+ */
+ setActor(actor: string): void {
+ this.currentActor = actor
+ }
+
+ /**
+ * Get the current operation source
+ */
+ getCurrentSource(): LayoutSource {
+ return this.currentSource
+ }
+
+ /**
+ * Get the current actor
+ */
+ getCurrentActor(): string {
+ return this.currentActor
+ }
+
+ /**
+ * Initialize store with existing nodes
+ */
+ initializeFromLiteGraph(
+ nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
+ ): void {
+ this.ydoc.transact(() => {
+ this.ynodes.clear()
+ this.nodeRefs.clear()
+ this.nodeTriggers.clear()
+ this.spatialIndex.clear()
+ this.linkSegmentSpatialIndex.clear()
+ this.slotSpatialIndex.clear()
+ this.rerouteSpatialIndex.clear()
+ this.linkLayouts.clear()
+ this.linkSegmentLayouts.clear()
+ this.slotLayouts.clear()
+ this.rerouteLayouts.clear()
+
+ nodes.forEach((node, index) => {
+ const layout: NodeLayout = {
+ id: node.id.toString(),
+ position: { x: node.pos[0], y: node.pos[1] },
+ size: { width: node.size[0], height: node.size[1] },
+ zIndex: index,
+ visible: true,
+ bounds: {
+ x: node.pos[0],
+ y: node.pos[1],
+ width: node.size[0],
+ height: node.size[1]
+ }
+ }
+
+ this.ynodes.set(layout.id, this.layoutToYNode(layout))
+
+ // Add to spatial index
+ this.spatialIndex.insert(layout.id, layout.bounds)
+ })
+ }, 'initialization')
+ }
+
+ // Operation handlers
+ private handleMoveNode(
+ operation: MoveNodeOperation,
+ change: LayoutChange
+ ): void {
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) {
+ return
+ }
+
+ const size = ynode.get('size') as { width: number; height: number }
+ const newBounds = {
+ x: operation.position.x,
+ y: operation.position.y,
+ width: size.width,
+ height: size.height
+ }
+
+ // Update spatial index FIRST, synchronously to prevent race conditions
+ // Hit detection queries can run before CRDT updates complete
+ this.spatialIndex.update(operation.nodeId, newBounds)
+
+ // Update associated slot positions synchronously
+ this.updateNodeSlotPositions(operation.nodeId, operation.position)
+
+ // Then update CRDT
+ ynode.set('position', operation.position)
+ this.updateNodeBounds(ynode, operation.position, size)
+
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleResizeNode(
+ operation: ResizeNodeOperation,
+ change: LayoutChange
+ ): void {
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) return
+
+ const position = ynode.get('position') as Point
+ const newBounds = {
+ x: position.x,
+ y: position.y,
+ width: operation.size.width,
+ height: operation.size.height
+ }
+
+ // Update spatial index FIRST, synchronously to prevent race conditions
+ // Hit detection queries can run before CRDT updates complete
+ this.spatialIndex.update(operation.nodeId, newBounds)
+
+ // Update associated slot positions synchronously (size changes may affect slot positions)
+ this.updateNodeSlotPositions(operation.nodeId, position)
+
+ // Then update CRDT
+ ynode.set('size', operation.size)
+ this.updateNodeBounds(ynode, position, operation.size)
+
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleSetNodeZIndex(
+ operation: SetNodeZIndexOperation,
+ change: LayoutChange
+ ): void {
+ const ynode = this.ynodes.get(operation.nodeId)
+ if (!ynode) return
+
+ ynode.set('zIndex', operation.zIndex)
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleCreateNode(
+ operation: CreateNodeOperation,
+ change: LayoutChange
+ ): void {
+ const ynode = this.layoutToYNode(operation.layout)
+ this.ynodes.set(operation.nodeId, ynode)
+
+ // Add to spatial index
+ this.spatialIndex.insert(operation.nodeId, operation.layout.bounds)
+
+ change.type = 'create'
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleDeleteNode(
+ operation: DeleteNodeOperation,
+ change: LayoutChange
+ ): void {
+ if (!this.ynodes.has(operation.nodeId)) return
+
+ this.ynodes.delete(operation.nodeId)
+ this.nodeRefs.delete(operation.nodeId)
+ this.nodeTriggers.delete(operation.nodeId)
+
+ // Remove from spatial index
+ this.spatialIndex.remove(operation.nodeId)
+
+ // Clean up associated slot layouts
+ this.deleteNodeSlotLayouts(operation.nodeId)
+
+ // Clean up associated links
+ const linksToDelete = this.findLinksConnectedToNode(operation.nodeId)
+
+ // Delete the associated links
+ for (const linkId of linksToDelete) {
+ this.ylinks.delete(String(linkId))
+ this.linkLayouts.delete(linkId)
+
+ // Clean up link segment layouts
+ this.cleanupLinkSegments(linkId)
+ }
+
+ change.type = 'delete'
+ change.nodeIds.push(operation.nodeId)
+ }
+
+ private handleCreateLink(
+ operation: CreateLinkOperation,
+ change: LayoutChange
+ ): void {
+ const linkData = new Y.Map()
+ linkData.set('id', operation.linkId)
+ linkData.set('sourceNodeId', operation.sourceNodeId)
+ linkData.set('sourceSlot', operation.sourceSlot)
+ linkData.set('targetNodeId', operation.targetNodeId)
+ linkData.set('targetSlot', operation.targetSlot)
+
+ this.ylinks.set(String(operation.linkId), linkData)
+
+ // Link geometry will be computed separately when nodes move
+ // This just tracks that the link exists
+ change.type = 'create'
+ }
+
+ private handleDeleteLink(
+ operation: DeleteLinkOperation,
+ change: LayoutChange
+ ): void {
+ if (!this.ylinks.has(String(operation.linkId))) return
+
+ this.ylinks.delete(String(operation.linkId))
+ this.linkLayouts.delete(operation.linkId)
+ // Clean up any segment layouts for this link
+ this.cleanupLinkSegments(operation.linkId)
+
+ change.type = 'delete'
+ }
+
+ private handleCreateReroute(
+ operation: CreateRerouteOperation,
+ change: LayoutChange
+ ): void {
+ const rerouteData = new Y.Map()
+ rerouteData.set('id', operation.rerouteId)
+ rerouteData.set('position', operation.position)
+ rerouteData.set('parentId', operation.parentId)
+ rerouteData.set('linkIds', operation.linkIds)
+
+ this.yreroutes.set(String(operation.rerouteId), rerouteData) // Yjs Map keys must be strings
+
+ // The observer will automatically update the spatial index
+ change.type = 'create'
+ }
+
+ private handleDeleteReroute(
+ operation: DeleteRerouteOperation,
+ change: LayoutChange
+ ): void {
+ if (!this.yreroutes.has(String(operation.rerouteId))) return // Yjs Map keys are strings
+
+ this.yreroutes.delete(String(operation.rerouteId)) // Yjs Map keys are strings
+ this.rerouteLayouts.delete(operation.rerouteId) // Layout map uses numeric ID
+ this.rerouteSpatialIndex.remove(String(operation.rerouteId)) // Spatial index uses strings
+
+ change.type = 'delete'
+ }
+
+ private handleMoveReroute(
+ operation: MoveRerouteOperation,
+ change: LayoutChange
+ ): void {
+ const yreroute = this.yreroutes.get(String(operation.rerouteId)) // Yjs Map keys are strings
+ if (!yreroute) return
+
+ yreroute.set('position', operation.position)
+
+ const pos = operation.position
+ const layout: RerouteLayout = {
+ id: operation.rerouteId,
+ position: pos,
+ radius: 8,
+ bounds: {
+ x: pos.x - 8,
+ y: pos.y - 8,
+ width: 16,
+ height: 16
+ }
+ }
+ this.updateRerouteLayout(operation.rerouteId, layout)
+
+ // Mark as update for listeners
+ change.type = 'update'
+ }
+
+ /**
+ * Update node bounds helper
+ */
+ private updateNodeBounds(
+ ynode: Y.Map,
+ position: Point,
+ size: { width: number; height: number }
+ ): void {
+ ynode.set('bounds', {
+ x: position.x,
+ y: position.y,
+ width: size.width,
+ height: size.height
+ })
+ }
+
+ /**
+ * Find all links connected to a specific node
+ */
+ private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
+ const connectedLinks: LinkId[] = []
+ this.ylinks.forEach((linkData: Y.Map, linkIdStr: string) => {
+ const linkId = Number(linkIdStr) as LinkId
+ const sourceNodeId = linkData.get('sourceNodeId') as NodeId
+ const targetNodeId = linkData.get('targetNodeId') as NodeId
+
+ if (sourceNodeId === nodeId || targetNodeId === nodeId) {
+ connectedLinks.push(linkId)
+ }
+ })
+ return connectedLinks
+ }
+
+ /**
+ * Handle link change events
+ */
+ private handleLinkChange(change: YEventChange, linkIdStr: string): void {
+ if (change.action === 'delete') {
+ const linkId = Number(linkIdStr) as LinkId
+ this.cleanupLinkData(linkId)
+ }
+ // Link was added or updated - geometry will be computed separately
+ // This just tracks that the link exists in CRDT
+ }
+
+ /**
+ * Clean up all data associated with a link
+ */
+ private cleanupLinkData(linkId: LinkId): void {
+ this.linkLayouts.delete(linkId)
+ this.cleanupLinkSegments(linkId)
+ }
+
+ /**
+ * Clean up all segment layouts for a link
+ */
+ private cleanupLinkSegments(linkId: LinkId): void {
+ const keysToDelete: string[] = []
+ for (const [key] of this.linkSegmentLayouts) {
+ if (key.startsWith(`${linkId}:`)) {
+ keysToDelete.push(key)
+ }
+ }
+
+ for (const key of keysToDelete) {
+ this.linkSegmentLayouts.delete(key)
+ this.linkSegmentSpatialIndex.remove(key)
+ }
+ }
+
+ /**
+ * Handle reroute change events
+ */
+ private handleRerouteChange(
+ change: YEventChange,
+ rerouteIdStr: string
+ ): void {
+ const rerouteId = Number(rerouteIdStr) as RerouteId
+
+ if (change.action === 'delete') {
+ this.handleRerouteDelete(rerouteId, rerouteIdStr)
+ } else if (change.action === 'update' || change.action === 'add') {
+ this.handleRerouteAddOrUpdate(rerouteId, rerouteIdStr)
+ }
+ }
+
+ /**
+ * Handle reroute deletion
+ */
+ private handleRerouteDelete(
+ rerouteId: RerouteId,
+ rerouteIdStr: string
+ ): void {
+ this.rerouteLayouts.delete(rerouteId)
+ this.rerouteSpatialIndex.remove(rerouteIdStr)
+ }
+
+ /**
+ * Handle reroute add or update
+ */
+ private handleRerouteAddOrUpdate(
+ rerouteId: RerouteId,
+ rerouteIdStr: string
+ ): void {
+ const rerouteData = this.yreroutes.get(rerouteIdStr)
+ if (!rerouteData) return
+
+ const position = rerouteData.get('position') as Point
+ if (!position) return
+
+ const layout = this.createRerouteLayout(rerouteId, position)
+ this.updateRerouteLayout(rerouteId, layout)
+ }
+
+ /**
+ * Create reroute layout from position
+ */
+ private createRerouteLayout(
+ rerouteId: RerouteId,
+ position: Point
+ ): RerouteLayout {
+ return {
+ id: rerouteId,
+ position,
+ radius: REROUTE_RADIUS,
+ bounds: {
+ x: position.x - REROUTE_RADIUS,
+ y: position.y - REROUTE_RADIUS,
+ width: REROUTE_RADIUS * 2,
+ height: REROUTE_RADIUS * 2
+ }
+ }
+ }
+
+ /**
+ * Update slot positions when a node moves
+ * TODO: This should be handled by the layout sync system (useSlotLayoutSync)
+ * rather than manually here. For now, we'll mark affected slots as needing recalculation.
+ */
+ private updateNodeSlotPositions(nodeId: NodeId, _nodePosition: Point): void {
+ // Mark all slots for this node as potentially stale
+ // The layout sync system will recalculate positions on the next frame
+ const slotsToRemove: string[] = []
+
+ for (const [key, slotLayout] of this.slotLayouts) {
+ if (slotLayout.nodeId === nodeId) {
+ slotsToRemove.push(key)
+ }
+ }
+
+ // Remove from spatial index so they'll be recalculated
+ for (const key of slotsToRemove) {
+ this.slotSpatialIndex.remove(key)
+ this.slotLayouts.delete(key)
+ }
+ }
+
+ // Helper methods
+ private layoutToYNode(layout: NodeLayout): Y.Map {
+ const ynode = new Y.Map()
+ ynode.set('id', layout.id)
+ ynode.set('position', layout.position)
+ ynode.set('size', layout.size)
+ ynode.set('zIndex', layout.zIndex)
+ ynode.set('visible', layout.visible)
+ ynode.set('bounds', layout.bounds)
+ return ynode
+ }
+
+ private yNodeToLayout(ynode: Y.Map): NodeLayout {
+ return {
+ id: ynode.get('id') as string,
+ position: ynode.get('position') as Point,
+ size: ynode.get('size') as { width: number; height: number },
+ zIndex: ynode.get('zIndex') as number,
+ visible: ynode.get('visible') as boolean,
+ bounds: ynode.get('bounds') as Bounds
+ }
+ }
+
+ private notifyChange(change: LayoutChange): void {
+ this.changeListeners.forEach((listener) => {
+ try {
+ listener(change)
+ } catch (error) {
+ console.error('Error in layout change listener:', error)
+ }
+ })
+ }
+
+ private pointInBounds(point: Point, bounds: Bounds): boolean {
+ return (
+ point.x >= bounds.x &&
+ point.x <= bounds.x + bounds.width &&
+ point.y >= bounds.y &&
+ point.y <= bounds.y + bounds.height
+ )
+ }
+
+ private boundsIntersect(a: Bounds, b: Bounds): boolean {
+ return !(
+ a.x + a.width < b.x ||
+ b.x + b.width < a.x ||
+ a.y + a.height < b.y ||
+ b.y + b.height < a.y
+ )
+ }
+
+ // CRDT-specific methods
+ getOperationsSince(timestamp: number): LayoutOperation[] {
+ const operations: LayoutOperation[] = []
+ this.yoperations.forEach((op: LayoutOperation) => {
+ if (op && op.timestamp > timestamp) {
+ operations.push(op)
+ }
+ })
+ return operations
+ }
+
+ getOperationsByActor(actor: string): LayoutOperation[] {
+ const operations: LayoutOperation[] = []
+ this.yoperations.forEach((op: LayoutOperation) => {
+ if (op && op.actor === actor) {
+ operations.push(op)
+ }
+ })
+ return operations
+ }
+
+ /**
+ * Get the Yjs document for network sync (future feature)
+ */
+ getYDoc(): Y.Doc {
+ return this.ydoc
+ }
+
+ /**
+ * Apply updates from remote peers (future feature)
+ */
+ applyUpdate(update: Uint8Array): void {
+ Y.applyUpdate(this.ydoc, update)
+ }
+
+ /**
+ * Get state as update for sending to peers (future feature)
+ */
+ getStateAsUpdate(): Uint8Array {
+ return Y.encodeStateAsUpdate(this.ydoc)
+ }
+}
+
+// Create singleton instance
+export const layoutStore = new LayoutStoreImpl()
+
+// Export types for convenience
+export type { LayoutStore } from '@/renderer/core/layout/types'
diff --git a/src/renderer/core/layout/sync/useLayoutSync.ts b/src/renderer/core/layout/sync/useLayoutSync.ts
new file mode 100644
index 0000000000..2aee7974cf
--- /dev/null
+++ b/src/renderer/core/layout/sync/useLayoutSync.ts
@@ -0,0 +1,79 @@
+/**
+ * Composable for syncing LiteGraph with the Layout system
+ *
+ * Implements one-way sync from Layout Store to LiteGraph.
+ * The layout store is the single source of truth.
+ */
+import { onUnmounted } from 'vue'
+
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+
+/**
+ * Composable for syncing LiteGraph with the Layout system
+ * This replaces the bidirectional sync with a one-way sync
+ */
+export function useLayoutSync() {
+ let unsubscribe: (() => void) | null = null
+
+ /**
+ * Start syncing from Layout system to LiteGraph
+ * This is one-way: Layout → LiteGraph only
+ */
+ function startSync(canvas: any) {
+ if (!canvas?.graph) return
+
+ // Subscribe to layout changes
+ unsubscribe = layoutStore.onChange((change) => {
+ // Apply changes to LiteGraph regardless of source
+ // The layout store is the single source of truth
+ for (const nodeId of change.nodeIds) {
+ const layout = layoutStore.getNodeLayoutRef(nodeId).value
+ if (!layout) continue
+
+ const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
+ if (!liteNode) continue
+
+ // Update position if changed
+ if (
+ liteNode.pos[0] !== layout.position.x ||
+ liteNode.pos[1] !== layout.position.y
+ ) {
+ liteNode.pos[0] = layout.position.x
+ liteNode.pos[1] = layout.position.y
+ }
+
+ // Update size if changed
+ if (
+ liteNode.size[0] !== layout.size.width ||
+ liteNode.size[1] !== layout.size.height
+ ) {
+ liteNode.size[0] = layout.size.width
+ liteNode.size[1] = layout.size.height
+ }
+ }
+
+ // Trigger single redraw for all changes
+ canvas.setDirty(true, true)
+ })
+ }
+
+ /**
+ * Stop syncing
+ */
+ function stopSync() {
+ if (unsubscribe) {
+ unsubscribe()
+ unsubscribe = null
+ }
+ }
+
+ // Auto-cleanup on unmount
+ onUnmounted(() => {
+ stopSync()
+ })
+
+ return {
+ startSync,
+ stopSync
+ }
+}
diff --git a/src/renderer/core/layout/sync/useLinkLayoutSync.ts b/src/renderer/core/layout/sync/useLinkLayoutSync.ts
new file mode 100644
index 0000000000..fd6b3b19c3
--- /dev/null
+++ b/src/renderer/core/layout/sync/useLinkLayoutSync.ts
@@ -0,0 +1,365 @@
+/**
+ * Composable for event-driven link layout synchronization
+ *
+ * Implements event-driven link layout updates decoupled from the render cycle.
+ * Updates link geometry only when it actually changes (node move/resize, link create/delete,
+ * reroute create/delete/move, collapse toggles).
+ */
+import log from 'loglevel'
+import { onUnmounted } from 'vue'
+
+import type { LGraph } from '@/lib/litegraph/src/LGraph'
+import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
+import { LLink } from '@/lib/litegraph/src/LLink'
+import { Reroute } from '@/lib/litegraph/src/Reroute'
+import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
+import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
+import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
+import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
+import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import type { LayoutChange } from '@/renderer/core/layout/types'
+
+const logger = log.getLogger('useLinkLayoutSync')
+
+/**
+ * Composable for managing link layout synchronization
+ */
+export function useLinkLayoutSync() {
+ let canvas: LGraphCanvas | null = null
+ let graph: LGraph | null = null
+ let offscreenCtx: CanvasRenderingContext2D | null = null
+ let adapter: LitegraphLinkAdapter | null = null
+ let unsubscribeLayoutChange: (() => void) | null = null
+ let restoreHandlers: (() => void) | null = null
+
+ /**
+ * Build link render context from canvas properties
+ */
+ function buildLinkRenderContext(): LinkRenderContext {
+ if (!canvas) {
+ throw new Error('Canvas not initialized')
+ }
+
+ return {
+ // Canvas settings
+ renderMode: canvas.links_render_mode,
+ connectionWidth: canvas.connections_width,
+ renderBorder: canvas.render_connections_border,
+ lowQuality: canvas.low_quality,
+ highQualityRender: canvas.highquality_render,
+ scale: canvas.ds.scale,
+ linkMarkerShape: canvas.linkMarkerShape,
+ renderConnectionArrows: canvas.render_connection_arrows,
+
+ // State
+ highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
+
+ // Colors
+ defaultLinkColor: canvas.default_link_color,
+ linkTypeColors: (canvas.constructor as any).link_type_colors || {},
+
+ // Pattern for disabled links
+ disabledPattern: canvas._pattern
+ }
+ }
+
+ /**
+ * Recompute a single link and all its segments
+ *
+ * Note: This logic mirrors LGraphCanvas#renderAllLinkSegments but:
+ * - Works with offscreen context for event-driven updates
+ * - No visibility checks (always computes full geometry)
+ * - No dragging state handling (pure geometry computation)
+ */
+ function recomputeLinkById(linkId: number): void {
+ if (!graph || !adapter || !offscreenCtx || !canvas) return
+
+ const link = graph.links.get(linkId)
+ if (!link || link.id === -1) return // Skip floating/temp links
+
+ // Get source and target nodes
+ const sourceNode = graph.getNodeById(link.origin_id)
+ const targetNode = graph.getNodeById(link.target_id)
+ if (!sourceNode || !targetNode) return
+
+ // Get slots
+ const sourceSlot = sourceNode.outputs?.[link.origin_slot]
+ const targetSlot = targetNode.inputs?.[link.target_slot]
+ if (!sourceSlot || !targetSlot) return
+
+ // Get positions
+ const startPos = getSlotPosition(sourceNode, link.origin_slot, false)
+ const endPos = getSlotPosition(targetNode, link.target_slot, true)
+
+ // Get directions
+ const startDir = sourceSlot.dir || LinkDirection.RIGHT
+ const endDir = targetSlot.dir || LinkDirection.LEFT
+
+ // Get reroutes for this link
+ const reroutes = LLink.getReroutes(graph, link)
+
+ // Build render context
+ const context = buildLinkRenderContext()
+
+ if (reroutes.length > 0) {
+ // Render segmented link with reroutes
+ let segmentStartPos = startPos
+ let segmentStartDir = startDir
+
+ for (let i = 0; i < reroutes.length; i++) {
+ const reroute = reroutes[i]
+
+ // Calculate reroute angle
+ reroute.calculateAngle(Date.now(), graph, [
+ segmentStartPos[0],
+ segmentStartPos[1]
+ ])
+
+ // Calculate control points
+ const distance = Math.sqrt(
+ (reroute.pos[0] - segmentStartPos[0]) ** 2 +
+ (reroute.pos[1] - segmentStartPos[1]) ** 2
+ )
+ const dist = Math.min(Reroute.maxSplineOffset, distance * 0.25)
+
+ // Special handling for floating input chain
+ const isFloatingInputChain = !sourceNode && targetNode
+ const startControl: ReadOnlyPoint = isFloatingInputChain
+ ? [0, 0]
+ : [dist * reroute.cos, dist * reroute.sin]
+
+ // Render segment to this reroute
+ adapter.renderLinkDirect(
+ offscreenCtx,
+ segmentStartPos,
+ reroute.pos,
+ link,
+ true, // skip_border
+ 0, // flow
+ null, // color
+ segmentStartDir,
+ LinkDirection.CENTER,
+ context,
+ {
+ startControl,
+ endControl: reroute.controlPoint,
+ reroute,
+ disabled: false
+ }
+ )
+
+ // Prepare for next segment
+ segmentStartPos = reroute.pos
+ segmentStartDir = LinkDirection.CENTER
+ }
+
+ // Render final segment from last reroute to target
+ const lastReroute = reroutes[reroutes.length - 1]
+ const finalDistance = Math.sqrt(
+ (endPos[0] - lastReroute.pos[0]) ** 2 +
+ (endPos[1] - lastReroute.pos[1]) ** 2
+ )
+ const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
+ const finalStartControl: ReadOnlyPoint = [
+ finalDist * lastReroute.cos,
+ finalDist * lastReroute.sin
+ ]
+
+ adapter.renderLinkDirect(
+ offscreenCtx,
+ lastReroute.pos,
+ endPos,
+ link,
+ true, // skip_border
+ 0, // flow
+ null, // color
+ LinkDirection.CENTER,
+ endDir,
+ context,
+ {
+ startControl: finalStartControl,
+ disabled: false
+ }
+ )
+ } else {
+ // No reroutes - render direct link
+ adapter.renderLinkDirect(
+ offscreenCtx,
+ startPos,
+ endPos,
+ link,
+ true, // skip_border
+ 0, // flow
+ null, // color
+ startDir,
+ endDir,
+ context,
+ {
+ disabled: false
+ }
+ )
+ }
+ }
+
+ /**
+ * Recompute all links connected to a node
+ */
+ function recomputeLinksForNode(nodeId: number): void {
+ if (!graph) return
+
+ const node = graph.getNodeById(nodeId)
+ if (!node) return
+
+ const linkIds = new Set()
+
+ // Collect output links
+ if (node.outputs) {
+ for (const output of node.outputs) {
+ if (output.links) {
+ for (const linkId of output.links) {
+ linkIds.add(linkId)
+ }
+ }
+ }
+ }
+
+ // Collect input links
+ if (node.inputs) {
+ for (const input of node.inputs) {
+ if (input.link !== null && input.link !== undefined) {
+ linkIds.add(input.link)
+ }
+ }
+ }
+
+ // Recompute each link
+ for (const linkId of linkIds) {
+ recomputeLinkById(linkId)
+ }
+ }
+
+ /**
+ * Recompute all links associated with a reroute
+ */
+ function recomputeLinksForReroute(rerouteId: number): void {
+ if (!graph) return
+
+ const reroute = graph.reroutes.get(rerouteId)
+ if (!reroute) return
+
+ // Recompute all links that pass through this reroute
+ for (const linkId of reroute.linkIds) {
+ recomputeLinkById(linkId)
+ }
+ }
+
+ /**
+ * Start link layout sync with event-driven functionality
+ */
+ function start(canvasInstance: LGraphCanvas): void {
+ canvas = canvasInstance
+ graph = canvas.graph
+ if (!graph) return
+
+ // Create offscreen canvas context
+ const offscreenCanvas = document.createElement('canvas')
+ offscreenCtx = offscreenCanvas.getContext('2d')
+ if (!offscreenCtx) {
+ logger.error('Failed to create offscreen canvas context')
+ return
+ }
+
+ // Create dedicated adapter with layout writes enabled
+ adapter = new LitegraphLinkAdapter(graph)
+ adapter.enableLayoutStoreWrites = true
+
+ // Initial computation for all existing links
+ for (const link of graph._links.values()) {
+ if (link.id !== -1) {
+ recomputeLinkById(link.id)
+ }
+ }
+
+ // Subscribe to layout store changes
+ unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => {
+ switch (change.operation.type) {
+ case 'moveNode':
+ case 'resizeNode':
+ recomputeLinksForNode(parseInt(change.operation.nodeId))
+ break
+ case 'createLink':
+ recomputeLinkById(change.operation.linkId)
+ break
+ case 'deleteLink':
+ // No-op - store already cleaned by existing code
+ break
+ case 'createReroute':
+ case 'deleteReroute':
+ // Recompute all affected links
+ if ('linkIds' in change.operation) {
+ for (const linkId of change.operation.linkIds) {
+ recomputeLinkById(linkId)
+ }
+ }
+ break
+ case 'moveReroute':
+ recomputeLinksForReroute(change.operation.rerouteId)
+ break
+ }
+ })
+
+ // Hook collapse events
+ const origTrigger = graph.onTrigger
+
+ graph.onTrigger = (action: string, param: any) => {
+ if (
+ action === 'node:property:changed' &&
+ param?.property === 'flags.collapsed'
+ ) {
+ const nodeId = parseInt(String(param.nodeId))
+ if (!isNaN(nodeId)) {
+ recomputeLinksForNode(nodeId)
+ }
+ }
+ if (origTrigger) {
+ origTrigger.call(graph, action, param)
+ }
+ }
+
+ // Store cleanup function
+ restoreHandlers = () => {
+ if (graph) {
+ graph.onTrigger = origTrigger || undefined
+ }
+ }
+ }
+
+ /**
+ * Stop link layout sync and cleanup all resources
+ */
+ function stop(): void {
+ if (unsubscribeLayoutChange) {
+ unsubscribeLayoutChange()
+ unsubscribeLayoutChange = null
+ }
+ if (restoreHandlers) {
+ restoreHandlers()
+ restoreHandlers = null
+ }
+ canvas = null
+ graph = null
+ offscreenCtx = null
+ adapter = null
+ }
+
+ // Auto-cleanup on unmount
+ onUnmounted(() => {
+ stop()
+ })
+
+ return {
+ start,
+ stop
+ }
+}
diff --git a/src/renderer/core/layout/sync/useSlotLayoutSync.ts b/src/renderer/core/layout/sync/useSlotLayoutSync.ts
new file mode 100644
index 0000000000..2bc7d53f34
--- /dev/null
+++ b/src/renderer/core/layout/sync/useSlotLayoutSync.ts
@@ -0,0 +1,163 @@
+/**
+ * Composable for managing slot layout registration
+ *
+ * Implements event-driven slot registration decoupled from the draw cycle.
+ * Registers slots once on initial load and keeps them updated when necessary.
+ */
+import { onUnmounted } from 'vue'
+
+import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
+import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
+import { type SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
+import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+
+/**
+ * Compute and register slot layouts for a node
+ * @param node LiteGraph node to process
+ */
+function computeAndRegisterSlots(node: LGraphNode): void {
+ const nodeId = String(node.id)
+ const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
+
+ // Fallback to live node values if layout not ready
+ const nodeX = nodeLayout?.position.x ?? node.pos[0]
+ const nodeY = nodeLayout?.position.y ?? node.pos[1]
+ const nodeWidth = nodeLayout?.size.width ?? node.size[0]
+ const nodeHeight = nodeLayout?.size.height ?? node.size[1]
+
+ // Ensure concrete slots & arrange when needed for accurate positions
+ node._setConcreteSlots()
+ const collapsed = node.flags.collapsed ?? false
+ if (!collapsed) {
+ node.arrange()
+ }
+
+ const context: SlotPositionContext = {
+ nodeX,
+ nodeY,
+ nodeWidth,
+ nodeHeight,
+ collapsed,
+ collapsedWidth: node._collapsed_width,
+ slotStartY: node.constructor.slot_start_y,
+ inputs: node.inputs,
+ outputs: node.outputs,
+ widgets: node.widgets
+ }
+
+ registerNodeSlots(nodeId, context)
+}
+
+/**
+ * Composable for managing slot layout registration
+ */
+export function useSlotLayoutSync() {
+ let unsubscribeLayoutChange: (() => void) | null = null
+ let restoreHandlers: (() => void) | null = null
+
+ /**
+ * Start slot layout sync with full event-driven functionality
+ * @param canvas LiteGraph canvas instance
+ */
+ function start(canvas: LGraphCanvas): void {
+ // When Vue nodes are enabled, slot DOM registers exact positions.
+ // Skip calculated registration to avoid conflicts.
+ if (LiteGraph.vueNodesMode) {
+ return
+ }
+ const graph = canvas?.graph
+ if (!graph) return
+
+ // Initial registration for all nodes in the current graph
+ for (const node of graph._nodes) {
+ computeAndRegisterSlots(node)
+ }
+
+ // Layout changes → recompute slots for changed nodes
+ unsubscribeLayoutChange = layoutStore.onChange((change) => {
+ for (const nodeId of change.nodeIds) {
+ const node = graph.getNodeById(parseInt(nodeId))
+ if (node) {
+ computeAndRegisterSlots(node)
+ }
+ }
+ })
+
+ // LiteGraph event hooks
+ const origNodeAdded = graph.onNodeAdded
+ const origNodeRemoved = graph.onNodeRemoved
+ const origTrigger = graph.onTrigger
+ const origAfterChange = graph.onAfterChange
+
+ graph.onNodeAdded = (node: LGraphNode) => {
+ computeAndRegisterSlots(node)
+ if (origNodeAdded) {
+ origNodeAdded.call(graph, node)
+ }
+ }
+
+ graph.onNodeRemoved = (node: LGraphNode) => {
+ layoutStore.deleteNodeSlotLayouts(String(node.id))
+ if (origNodeRemoved) {
+ origNodeRemoved.call(graph, node)
+ }
+ }
+
+ graph.onTrigger = (action: string, param: any) => {
+ if (
+ action === 'node:property:changed' &&
+ param?.property === 'flags.collapsed'
+ ) {
+ const node = graph.getNodeById(parseInt(String(param.nodeId)))
+ if (node) {
+ computeAndRegisterSlots(node)
+ }
+ }
+ if (origTrigger) {
+ origTrigger.call(graph, action, param)
+ }
+ }
+
+ graph.onAfterChange = (graph: any, node?: any) => {
+ if (node && node.id) {
+ computeAndRegisterSlots(node)
+ }
+ if (origAfterChange) {
+ origAfterChange.call(graph, graph, node)
+ }
+ }
+
+ // Store cleanup function
+ restoreHandlers = () => {
+ graph.onNodeAdded = origNodeAdded || undefined
+ graph.onNodeRemoved = origNodeRemoved || undefined
+ graph.onTrigger = origTrigger || undefined
+ graph.onAfterChange = origAfterChange || undefined
+ }
+ }
+
+ /**
+ * Stop slot layout sync and cleanup all subscriptions
+ */
+ function stop(): void {
+ if (unsubscribeLayoutChange) {
+ unsubscribeLayoutChange()
+ unsubscribeLayoutChange = null
+ }
+ if (restoreHandlers) {
+ restoreHandlers()
+ restoreHandlers = null
+ }
+ }
+
+ // Auto-cleanup on unmount
+ onUnmounted(() => {
+ stop()
+ })
+
+ return {
+ start,
+ stop
+ }
+}
diff --git a/src/renderer/core/layout/types.ts b/src/renderer/core/layout/types.ts
new file mode 100644
index 0000000000..8ff25ea25c
--- /dev/null
+++ b/src/renderer/core/layout/types.ts
@@ -0,0 +1,322 @@
+/**
+ * Layout System - Type Definitions
+ *
+ * This file contains all type definitions for the layout system
+ * that manages node positions, bounds, spatial data, and operations.
+ */
+import type { ComputedRef, Ref } from 'vue'
+
+// Enum for layout source types
+export enum LayoutSource {
+ Canvas = 'canvas',
+ Vue = 'vue',
+ External = 'external'
+}
+
+// Basic geometric types
+export interface Point {
+ x: number
+ y: number
+}
+
+export interface Size {
+ width: number
+ height: number
+}
+
+export interface Bounds {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export type NodeId = string
+export type LinkId = number
+export type RerouteId = number
+
+// Layout data structures
+export interface NodeLayout {
+ id: NodeId
+ position: Point
+ size: Size
+ zIndex: number
+ visible: boolean
+ // Computed bounds for hit testing
+ bounds: Bounds
+}
+
+export interface SlotLayout {
+ nodeId: NodeId
+ index: number
+ type: 'input' | 'output'
+ position: Point
+ bounds: Bounds
+}
+
+export interface LinkLayout {
+ id: LinkId
+ path: Path2D
+ bounds: Bounds
+ centerPos: Point
+ sourceNodeId: NodeId
+ targetNodeId: NodeId
+ sourceSlot: number
+ targetSlot: number
+}
+
+// Layout for individual link segments (for precise hit-testing)
+export interface LinkSegmentLayout {
+ linkId: LinkId
+ rerouteId: RerouteId | null // null for final segment to target
+ path: Path2D
+ bounds: Bounds
+ centerPos: Point
+}
+
+export interface RerouteLayout {
+ id: RerouteId
+ position: Point
+ radius: number
+ bounds: Bounds
+}
+
+/**
+ * Meta-only base for all operations - contains common fields
+ */
+export interface OperationMeta {
+ /** Unique operation ID for deduplication */
+ id?: string
+ /** Timestamp for ordering operations */
+ timestamp: number
+ /** Actor who performed the operation (for CRDT) */
+ actor: string
+ /** Source system that initiated the operation */
+ source: LayoutSource
+ /** Operation type discriminator */
+ type: OperationType
+}
+
+/**
+ * Entity-specific base types for proper type discrimination
+ */
+export type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
+export type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
+export type RerouteOpBase = OperationMeta & {
+ entity: 'reroute'
+ rerouteId: RerouteId
+}
+
+/**
+ * Operation type discriminator for type narrowing
+ */
+export type OperationType =
+ | 'moveNode'
+ | 'resizeNode'
+ | 'setNodeZIndex'
+ | 'createNode'
+ | 'deleteNode'
+ | 'setNodeVisibility'
+ | 'batchUpdate'
+ | 'createLink'
+ | 'deleteLink'
+ | 'createReroute'
+ | 'deleteReroute'
+ | 'moveReroute'
+
+/**
+ * Move node operation
+ */
+export interface MoveNodeOperation extends NodeOpBase {
+ type: 'moveNode'
+ position: Point
+ previousPosition: Point
+}
+
+/**
+ * Resize node operation
+ */
+export interface ResizeNodeOperation extends NodeOpBase {
+ type: 'resizeNode'
+ size: { width: number; height: number }
+ previousSize: { width: number; height: number }
+}
+
+/**
+ * Set node z-index operation
+ */
+export interface SetNodeZIndexOperation extends NodeOpBase {
+ type: 'setNodeZIndex'
+ zIndex: number
+ previousZIndex: number
+}
+
+/**
+ * Create node operation
+ */
+export interface CreateNodeOperation extends NodeOpBase {
+ type: 'createNode'
+ layout: NodeLayout
+}
+
+/**
+ * Delete node operation
+ */
+export interface DeleteNodeOperation extends NodeOpBase {
+ type: 'deleteNode'
+ previousLayout: NodeLayout
+}
+
+/**
+ * Set node visibility operation
+ */
+export interface SetNodeVisibilityOperation extends NodeOpBase {
+ type: 'setNodeVisibility'
+ visible: boolean
+ previousVisible: boolean
+}
+
+/**
+ * Batch update operation for atomic multi-property changes
+ */
+export interface BatchUpdateOperation extends NodeOpBase {
+ type: 'batchUpdate'
+ updates: Partial
+ previousValues: Partial
+}
+
+/**
+ * Create link operation
+ */
+export interface CreateLinkOperation extends LinkOpBase {
+ type: 'createLink'
+ sourceNodeId: NodeId
+ sourceSlot: number
+ targetNodeId: NodeId
+ targetSlot: number
+}
+
+/**
+ * Delete link operation
+ */
+export interface DeleteLinkOperation extends LinkOpBase {
+ type: 'deleteLink'
+}
+
+/**
+ * Create reroute operation
+ */
+export interface CreateRerouteOperation extends RerouteOpBase {
+ type: 'createReroute'
+ position: Point
+ parentId?: RerouteId
+ linkIds: LinkId[]
+}
+
+/**
+ * Delete reroute operation
+ */
+export interface DeleteRerouteOperation extends RerouteOpBase {
+ type: 'deleteReroute'
+}
+
+/**
+ * Move reroute operation
+ */
+export interface MoveRerouteOperation extends RerouteOpBase {
+ type: 'moveReroute'
+ position: Point
+ previousPosition: Point
+}
+
+/**
+ * Union of all operation types
+ */
+export type LayoutOperation =
+ | MoveNodeOperation
+ | ResizeNodeOperation
+ | SetNodeZIndexOperation
+ | CreateNodeOperation
+ | DeleteNodeOperation
+ | SetNodeVisibilityOperation
+ | BatchUpdateOperation
+ | CreateLinkOperation
+ | DeleteLinkOperation
+ | CreateRerouteOperation
+ | DeleteRerouteOperation
+ | MoveRerouteOperation
+
+export interface LayoutChange {
+ type: 'create' | 'update' | 'delete'
+ nodeIds: NodeId[]
+ timestamp: number
+ source: LayoutSource
+ operation: LayoutOperation
+}
+
+// Store interfaces
+export interface LayoutStore {
+ // CustomRef accessors for shared write access
+ getNodeLayoutRef(nodeId: NodeId): Ref
+ getNodesInBounds(bounds: Bounds): ComputedRef
+ getAllNodes(): ComputedRef>
+ getVersion(): ComputedRef
+
+ // Spatial queries (non-reactive)
+ queryNodeAtPoint(point: Point): NodeId | null
+ queryNodesInBounds(bounds: Bounds): NodeId[]
+
+ // Hit testing queries for links, slots, and reroutes
+ queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null
+ queryLinkSegmentAtPoint(
+ point: Point,
+ ctx?: CanvasRenderingContext2D
+ ): { linkId: LinkId; rerouteId: RerouteId | null } | null
+ querySlotAtPoint(point: Point): SlotLayout | null
+ queryRerouteAtPoint(point: Point): RerouteLayout | null
+ queryItemsInBounds(bounds: Bounds): {
+ nodes: NodeId[]
+ links: LinkId[]
+ slots: string[]
+ reroutes: RerouteId[]
+ }
+
+ // Update methods for link, slot, and reroute layouts
+ updateLinkLayout(linkId: LinkId, layout: LinkLayout): void
+ updateLinkSegmentLayout(
+ linkId: LinkId,
+ rerouteId: RerouteId | null,
+ layout: Omit
+ ): void
+ updateSlotLayout(key: string, layout: SlotLayout): void
+ updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void
+
+ // Delete methods for cleanup
+ deleteLinkLayout(linkId: LinkId): void
+ deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void
+ deleteSlotLayout(key: string): void
+ deleteNodeSlotLayouts(nodeId: NodeId): void
+ deleteRerouteLayout(rerouteId: RerouteId): void
+
+ // Get layout data
+ getLinkLayout(linkId: LinkId): LinkLayout | null
+ getSlotLayout(key: string): SlotLayout | null
+ getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
+
+ // Direct mutation API (CRDT-ready)
+ applyOperation(operation: LayoutOperation): void
+
+ // Change subscription
+ onChange(callback: (change: LayoutChange) => void): () => void
+
+ // Initialization
+ initializeFromLiteGraph(
+ nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
+ ): void
+
+ // Source and actor management
+ setSource(source: LayoutSource): void
+ setActor(actor: string): void
+ getCurrentSource(): LayoutSource
+ getCurrentActor(): string
+}
diff --git a/src/renderer/core/spatial/SpatialIndex.ts b/src/renderer/core/spatial/SpatialIndex.ts
new file mode 100644
index 0000000000..5b6fb269c4
--- /dev/null
+++ b/src/renderer/core/spatial/SpatialIndex.ts
@@ -0,0 +1,169 @@
+/**
+ * Spatial Index Manager
+ *
+ * Manages spatial indexing for efficient node queries based on bounds.
+ * Uses QuadTree for fast spatial lookups with caching for performance.
+ */
+import {
+ PERFORMANCE_CONFIG,
+ QUADTREE_CONFIG
+} from '@/renderer/core/layout/constants'
+import type { Bounds, NodeId } from '@/renderer/core/layout/types'
+import { QuadTree } from '@/utils/spatial/QuadTree'
+
+/**
+ * Cache entry for spatial queries
+ */
+interface CacheEntry {
+ result: NodeId[]
+ timestamp: number
+}
+
+/**
+ * Spatial index manager using QuadTree
+ */
+export class SpatialIndexManager {
+ private quadTree: QuadTree
+ private queryCache: Map
+ private cacheSize = 0
+
+ constructor(bounds?: Bounds) {
+ this.quadTree = new QuadTree(
+ bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
+ {
+ maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
+ maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE
+ }
+ )
+ this.queryCache = new Map()
+ }
+
+ /**
+ * Insert a node into the spatial index
+ */
+ insert(nodeId: NodeId, bounds: Bounds): void {
+ this.quadTree.insert(nodeId, bounds, nodeId)
+ this.invalidateCache()
+ }
+
+ /**
+ * Update a node's bounds in the spatial index
+ */
+ update(nodeId: NodeId, bounds: Bounds): void {
+ this.quadTree.update(nodeId, bounds)
+ this.invalidateCache()
+ }
+
+ /**
+ * Remove a node from the spatial index
+ */
+ remove(nodeId: NodeId): void {
+ this.quadTree.remove(nodeId)
+ this.invalidateCache()
+ }
+
+ /**
+ * Query nodes within the given bounds
+ */
+ query(bounds: Bounds): NodeId[] {
+ const cacheKey = this.getCacheKey(bounds)
+ const cached = this.queryCache.get(cacheKey)
+
+ // Check cache validity
+ if (cached) {
+ const age = Date.now() - cached.timestamp
+ if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) {
+ return cached.result
+ }
+ // Remove stale entry
+ this.queryCache.delete(cacheKey)
+ this.cacheSize--
+ }
+
+ // Perform query
+ const result = this.quadTree.query(bounds)
+
+ // Cache result
+ this.addToCache(cacheKey, result)
+
+ return result
+ }
+
+ /**
+ * Clear all nodes from the spatial index
+ */
+ clear(): void {
+ this.quadTree.clear()
+ this.invalidateCache()
+ }
+
+ /**
+ * Get the current size of the index
+ */
+ get size(): number {
+ return this.quadTree.size
+ }
+
+ /**
+ * Get debug information about the spatial index
+ */
+ getDebugInfo() {
+ return {
+ quadTreeInfo: this.quadTree.getDebugInfo(),
+ cacheSize: this.cacheSize,
+ cacheEntries: this.queryCache.size
+ }
+ }
+
+ /**
+ * Generate cache key for bounds
+ */
+ private getCacheKey(bounds: Bounds): string {
+ return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
+ }
+
+ /**
+ * Add result to cache with LRU eviction
+ */
+ private addToCache(key: string, result: NodeId[]): void {
+ // Evict oldest entries if cache is full
+ if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
+ const oldestKey = this.findOldestCacheEntry()
+ if (oldestKey) {
+ this.queryCache.delete(oldestKey)
+ this.cacheSize--
+ }
+ }
+
+ this.queryCache.set(key, {
+ result,
+ timestamp: Date.now()
+ })
+ this.cacheSize++
+ }
+
+ /**
+ * Find oldest cache entry for LRU eviction
+ */
+ private findOldestCacheEntry(): string | null {
+ let oldestKey: string | null = null
+ let oldestTime = Infinity
+
+ for (const [key, entry] of this.queryCache) {
+ if (entry.timestamp < oldestTime) {
+ oldestTime = entry.timestamp
+ oldestKey = key
+ }
+ }
+
+ return oldestKey
+ }
+
+ /**
+ * Invalidate all cached queries
+ */
+ private invalidateCache(): void {
+ this.queryCache.clear()
+ this.cacheSize = 0
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue
new file mode 100644
index 0000000000..52dbacac05
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue
@@ -0,0 +1,107 @@
+
+ ⚠️
+
+
+
+
+
+
+ {{ slotData.localized_name || slotData.name || `Input ${index}` }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
new file mode 100644
index 0000000000..4ce4e21001
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue
@@ -0,0 +1,271 @@
+
+
+ {{ $t('Node Render Error') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeContent.vue b/src/renderer/extensions/vueNodes/components/NodeContent.vue
new file mode 100644
index 0000000000..f99e109177
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/NodeContent.vue
@@ -0,0 +1,40 @@
+
+
+ {{ $t('Node Content Error') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeHeader.vue b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
new file mode 100644
index 0000000000..3b3da31013
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/NodeHeader.vue
@@ -0,0 +1,115 @@
+
+
+ {{ $t('Node Header Error') }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeSlots.vue b/src/renderer/extensions/vueNodes/components/NodeSlots.vue
new file mode 100644
index 0000000000..68f2479321
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/NodeSlots.vue
@@ -0,0 +1,113 @@
+
+
+ {{ $t('Node Slots Error') }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/NodeWidgets.vue b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
new file mode 100644
index 0000000000..0cd7a59cc5
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/NodeWidgets.vue
@@ -0,0 +1,155 @@
+
+
+ {{ $t('Node Widgets Error') }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/OutputSlot.vue b/src/renderer/extensions/vueNodes/components/OutputSlot.vue
new file mode 100644
index 0000000000..144d5978d5
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/OutputSlot.vue
@@ -0,0 +1,106 @@
+
+ ⚠️
+
+
+
+ {{ slotData.name || `Output ${index}` }}
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue
new file mode 100644
index 0000000000..d5b8b1ad8f
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/SlotConnectionDot.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/components/widgets/LOD_IMPLEMENTATION_GUIDE.md b/src/renderer/extensions/vueNodes/components/widgets/LOD_IMPLEMENTATION_GUIDE.md
new file mode 100644
index 0000000000..95c8b40a37
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/components/widgets/LOD_IMPLEMENTATION_GUIDE.md
@@ -0,0 +1,295 @@
+# Level of Detail (LOD) Implementation Guide for Widgets
+
+## What is Level of Detail (LOD)?
+
+Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
+
+For ComfyUI nodes, this means:
+- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
+- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
+
+## Why LOD Matters
+
+Without LOD optimization:
+- 1000+ nodes with full detail = browser lag and poor performance
+- Text that's too small to read still gets rendered (wasted work)
+- Visual effects that are invisible at distance still consume GPU
+
+With LOD optimization:
+- Smooth performance even with large node graphs
+- Battery life improvement on laptops
+- Better user experience across different zoom levels
+
+## How to Implement LOD in Your Widget
+
+### Step 1: Get the LOD Context
+
+Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
+
+```vue
+
+```
+
+**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
+**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
+
+### Step 2: Choose What to Show at Different Zoom Levels
+
+#### Understanding the LOD Score
+- `lodScore` is a number from 0 to 1
+- 0 = completely zoomed out (show minimal detail)
+- 1 = fully zoomed in (show everything)
+- 0.5 = medium zoom (show some details)
+
+#### Understanding LOD Levels
+- `'minimal'` = zoom level 0.4 or below (very zoomed out)
+- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
+- `'full'` = zoom level 0.8 or above (zoomed in close)
+
+### Step 3: Implement Your Widget's LOD Strategy
+
+Here's a complete example of a slider widget with LOD:
+
+```vue
+
+
+
+
+
+
+
+```
+
+## Common LOD Patterns
+
+### Pattern 1: Essential vs. Nice-to-Have
+```typescript
+// Always show the main functionality
+const showMainControl = computed(() => true)
+
+// Granular control with lodScore
+const showLabels = computed(() => lodScore.value > 0.4)
+const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
+
+// Simple control with lodLevel
+const showExtras = computed(() => lodLevel.value === 'full')
+```
+
+### Pattern 2: Smooth Opacity Transitions
+```typescript
+// Gradually fade elements based on zoom
+const labelOpacity = computed(() => {
+ // Fade in from zoom 0.3 to 0.6
+ return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
+})
+```
+
+### Pattern 3: Progressive Detail
+```typescript
+const detailLevel = computed(() => {
+ if (lodScore.value < 0.3) return 'none'
+ if (lodScore.value < 0.6) return 'basic'
+ if (lodScore.value < 0.8) return 'standard'
+ return 'full'
+})
+```
+
+## LOD Guidelines by Widget Type
+
+### Text Input Widgets
+- **Always show**: The input field itself
+- **Medium zoom**: Show label
+- **High zoom**: Show placeholder text, validation messages
+- **Full zoom**: Show character count, format hints
+
+### Button Widgets
+- **Always show**: The button
+- **Medium zoom**: Show button text
+- **High zoom**: Show button description
+- **Full zoom**: Show keyboard shortcuts, tooltips
+
+### Selection Widgets (Dropdown, Radio)
+- **Always show**: The current selection
+- **Medium zoom**: Show option labels
+- **High zoom**: Show all options when expanded
+- **Full zoom**: Show option descriptions, icons
+
+### Complex Widgets (Color Picker, File Browser)
+- **Always show**: Simplified representation (color swatch, filename)
+- **Medium zoom**: Show basic controls
+- **High zoom**: Show full interface
+- **Full zoom**: Show advanced options, previews
+
+## Design Collaboration Guidelines
+
+### For Designers
+When designing widgets, consider creating variants for different zoom levels:
+
+1. **Minimal Design** (far away view)
+ - Essential elements only
+ - Higher contrast for visibility
+ - Simplified shapes and fewer details
+
+2. **Standard Design** (normal view)
+ - Balanced detail and simplicity
+ - Clear labels and readable text
+ - Good for most use cases
+
+3. **Full Detail Design** (close-up view)
+ - All labels, descriptions, and help text
+ - Rich visual effects and polish
+ - Maximum information density
+
+### Design Handoff Checklist
+- [ ] Specify which elements are essential vs. nice-to-have
+- [ ] Define minimum readable sizes for text elements
+- [ ] Provide simplified versions for distant viewing
+- [ ] Consider color contrast at different opacity levels
+- [ ] Test designs at multiple zoom levels
+
+## Testing Your LOD Implementation
+
+### Manual Testing
+1. Create a workflow with your widget
+2. Zoom out until nodes are very small
+3. Verify essential functionality still works
+4. Zoom in gradually and check that details appear smoothly
+5. Test performance with 50+ nodes containing your widget
+
+### Performance Considerations
+- Avoid complex calculations in LOD computed properties
+- Use `v-if` instead of `v-show` for elements that won't render
+- Consider using `v-memo` for expensive widget content
+- Test on lower-end devices
+
+### Common Mistakes
+❌ **Don't**: Hide the main widget functionality at any zoom level
+❌ **Don't**: Use complex animations that trigger at every zoom change
+❌ **Don't**: Make LOD thresholds too sensitive (causes flickering)
+❌ **Don't**: Forget to test with real content and edge cases
+
+✅ **Do**: Keep essential functionality always visible
+✅ **Do**: Use smooth transitions between LOD levels
+✅ **Do**: Test with varying content lengths and types
+✅ **Do**: Consider accessibility at all zoom levels
+
+## Getting Help
+
+- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
+- Ask in the ComfyUI frontend Discord for LOD implementation questions
+- Test your changes with the LOD debug panel (top-right in GraphCanvas)
+- Profile performance impact using browser dev tools
\ No newline at end of file
diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
new file mode 100644
index 0000000000..407a14243e
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
@@ -0,0 +1,168 @@
+/**
+ * Composable for individual Vue node components
+ *
+ * Uses customRef for shared write access with Canvas renderer.
+ * Provides dragging functionality and reactive layout state.
+ */
+import { computed, inject } from 'vue'
+
+import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import { LayoutSource, type Point } from '@/renderer/core/layout/types'
+
+/**
+ * Composable for individual Vue node components
+ * Uses customRef for shared write access with Canvas renderer
+ */
+export function useNodeLayout(nodeId: string) {
+ const store = layoutStore
+ const mutations = useLayoutMutations()
+
+ // Get transform utilities from TransformPane if available
+ const transformState = inject('transformState') as
+ | {
+ canvasToScreen: (point: Point) => Point
+ screenToCanvas: (point: Point) => Point
+ }
+ | undefined
+
+ // Get the customRef for this node (shared write access)
+ const layoutRef = store.getNodeLayoutRef(nodeId)
+
+ // Computed properties for easy access
+ const position = computed(() => {
+ const layout = layoutRef.value
+ const pos = layout?.position ?? { x: 0, y: 0 }
+ return pos
+ })
+ const size = computed(
+ () => layoutRef.value?.size ?? { width: 200, height: 100 }
+ )
+ const bounds = computed(
+ () =>
+ layoutRef.value?.bounds ?? {
+ x: position.value.x,
+ y: position.value.y,
+ width: size.value.width,
+ height: size.value.height
+ }
+ )
+ const isVisible = computed(() => layoutRef.value?.visible ?? true)
+ const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
+
+ // Drag state
+ let isDragging = false
+ let dragStartPos: Point | null = null
+ let dragStartMouse: Point | null = null
+
+ /**
+ * Start dragging the node
+ */
+ function startDrag(event: PointerEvent) {
+ if (!layoutRef.value) return
+
+ isDragging = true
+ dragStartPos = { ...position.value }
+ dragStartMouse = { x: event.clientX, y: event.clientY }
+
+ // Set mutation source
+ mutations.setSource(LayoutSource.Vue)
+
+ // Capture pointer
+ const target = event.target as HTMLElement
+ target.setPointerCapture(event.pointerId)
+ }
+
+ /**
+ * Handle drag movement
+ */
+ const handleDrag = (event: PointerEvent) => {
+ if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
+ return
+ }
+
+ // Calculate mouse delta in screen coordinates
+ const mouseDelta = {
+ x: event.clientX - dragStartMouse.x,
+ y: event.clientY - dragStartMouse.y
+ }
+
+ // Convert to canvas coordinates
+ const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
+ const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
+ const canvasDelta = {
+ x: canvasWithDelta.x - canvasOrigin.x,
+ y: canvasWithDelta.y - canvasOrigin.y
+ }
+
+ // Calculate new position
+ const newPosition = {
+ x: dragStartPos.x + canvasDelta.x,
+ y: dragStartPos.y + canvasDelta.y
+ }
+
+ // Apply mutation through the layout system
+ mutations.moveNode(nodeId, newPosition)
+ }
+
+ /**
+ * End dragging
+ */
+ function endDrag(event: PointerEvent) {
+ if (!isDragging) return
+
+ isDragging = false
+ dragStartPos = null
+ dragStartMouse = null
+
+ // Release pointer
+ const target = event.target as HTMLElement
+ target.releasePointerCapture(event.pointerId)
+ }
+
+ /**
+ * Update node position directly (without drag)
+ */
+ function moveTo(position: Point) {
+ mutations.setSource(LayoutSource.Vue)
+ mutations.moveNode(nodeId, position)
+ }
+
+ /**
+ * Update node size
+ */
+ function resize(newSize: { width: number; height: number }) {
+ mutations.setSource(LayoutSource.Vue)
+ mutations.resizeNode(nodeId, newSize)
+ }
+
+ return {
+ // Reactive state (via customRef)
+ layoutRef,
+ position,
+ size,
+ bounds,
+ isVisible,
+ zIndex,
+
+ // Mutations
+ moveTo,
+ resize,
+
+ // Drag handlers
+ startDrag,
+ handleDrag,
+ endDrag,
+
+ // Computed styles for Vue templates
+ nodeStyle: computed(() => ({
+ position: 'absolute' as const,
+ left: `${position.value.x}px`,
+ top: `${position.value.y}px`,
+ width: `${size.value.width}px`,
+ height: `${size.value.height}px`,
+ zIndex: zIndex.value,
+ cursor: isDragging ? 'grabbing' : 'grab'
+ }))
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/lod/useLOD.ts b/src/renderer/extensions/vueNodes/lod/useLOD.ts
new file mode 100644
index 0000000000..9d07e37ca3
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/lod/useLOD.ts
@@ -0,0 +1,186 @@
+/**
+ * Level of Detail (LOD) composable for Vue-based node rendering
+ *
+ * Provides dynamic quality adjustment based on zoom level to maintain
+ * performance with large node graphs. Uses zoom thresholds to determine
+ * how much detail to render for each node component.
+ *
+ * ## LOD Levels
+ *
+ * - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
+ * - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
+ * - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
+ *
+ * ## Performance Benefits
+ *
+ * - Reduces DOM element count by up to 80% at low zoom levels
+ * - Minimizes layout calculations and paint operations
+ * - Enables smooth performance with 1000+ nodes
+ * - Maintains visual fidelity when detail is actually visible
+ *
+ * @example
+ * ```typescript
+ * const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
+ *
+ * // In template
+ *
+ *
+ * ```
+ */
+import { type Ref, computed, readonly } from 'vue'
+
+export enum LODLevel {
+ MINIMAL = 'minimal', // zoom <= 0.4
+ REDUCED = 'reduced', // 0.4 < zoom <= 0.8
+ FULL = 'full' // zoom > 0.8
+}
+
+export interface LODConfig {
+ renderWidgets: boolean
+ renderSlots: boolean
+ renderContent: boolean
+ renderSlotLabels: boolean
+ renderWidgetLabels: boolean
+ cssClass: string
+}
+
+// LOD configuration for each level
+const LOD_CONFIGS: Record = {
+ [LODLevel.FULL]: {
+ renderWidgets: true,
+ renderSlots: true,
+ renderContent: true,
+ renderSlotLabels: true,
+ renderWidgetLabels: true,
+ cssClass: 'lg-node--lod-full'
+ },
+ [LODLevel.REDUCED]: {
+ renderWidgets: true,
+ renderSlots: true,
+ renderContent: false,
+ renderSlotLabels: false,
+ renderWidgetLabels: false,
+ cssClass: 'lg-node--lod-reduced'
+ },
+ [LODLevel.MINIMAL]: {
+ renderWidgets: false,
+ renderSlots: false,
+ renderContent: false,
+ renderSlotLabels: false,
+ renderWidgetLabels: false,
+ cssClass: 'lg-node--lod-minimal'
+ }
+}
+
+/**
+ * Create LOD (Level of Detail) state based on zoom level
+ *
+ * @param zoomRef - Reactive reference to current zoom level (camera.z)
+ * @returns LOD state and configuration
+ */
+export function useLOD(zoomRef: Ref) {
+ // Continuous LOD score (0-1) for smooth transitions
+ const lodScore = computed(() => {
+ const zoom = zoomRef.value
+ return Math.max(0, Math.min(1, zoom))
+ })
+
+ // Determine current LOD level based on zoom
+ const lodLevel = computed(() => {
+ const zoom = zoomRef.value
+
+ if (zoom > 0.8) return LODLevel.FULL
+ if (zoom > 0.4) return LODLevel.REDUCED
+ return LODLevel.MINIMAL
+ })
+
+ // Get configuration for current LOD level
+ const lodConfig = computed(() => LOD_CONFIGS[lodLevel.value])
+
+ // Convenience computed properties for common rendering decisions
+ const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
+ const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
+ const shouldRenderContent = computed(() => lodConfig.value.renderContent)
+ const shouldRenderSlotLabels = computed(
+ () => lodConfig.value.renderSlotLabels
+ )
+ const shouldRenderWidgetLabels = computed(
+ () => lodConfig.value.renderWidgetLabels
+ )
+
+ // CSS class for styling based on LOD level
+ const lodCssClass = computed(() => lodConfig.value.cssClass)
+
+ // Get essential widgets for reduced LOD (only interactive controls)
+ const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
+ if (lodLevel.value === LODLevel.FULL) return widgets
+ if (lodLevel.value === LODLevel.MINIMAL) return []
+
+ // For reduced LOD, filter to essential widget types only
+ return widgets.filter((widget: any) => {
+ const type = widget?.type?.toLowerCase()
+ return [
+ 'combo',
+ 'select',
+ 'toggle',
+ 'boolean',
+ 'slider',
+ 'number'
+ ].includes(type)
+ })
+ }
+
+ // Performance metrics for debugging
+ const lodMetrics = computed(() => ({
+ level: lodLevel.value,
+ zoom: zoomRef.value,
+ widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
+ slotCount: shouldRenderSlots.value ? 'full' : 'none'
+ }))
+
+ return {
+ // Core LOD state
+ lodLevel: readonly(lodLevel),
+ lodConfig: readonly(lodConfig),
+ lodScore: readonly(lodScore),
+
+ // Rendering decisions
+ shouldRenderWidgets,
+ shouldRenderSlots,
+ shouldRenderContent,
+ shouldRenderSlotLabels,
+ shouldRenderWidgetLabels,
+
+ // Styling
+ lodCssClass,
+
+ // Utilities
+ getEssentialWidgets,
+ lodMetrics
+ }
+}
+
+/**
+ * Get LOD level thresholds for configuration or debugging
+ */
+export const LOD_THRESHOLDS = {
+ FULL_THRESHOLD: 0.8,
+ REDUCED_THRESHOLD: 0.4,
+ MINIMAL_THRESHOLD: 0.0
+} as const
+
+/**
+ * Check if zoom level supports a specific feature
+ */
+export function supportsFeatureAtZoom(
+ zoom: number,
+ feature: keyof LODConfig
+): boolean {
+ const level =
+ zoom > 0.8
+ ? LODLevel.FULL
+ : zoom > 0.4
+ ? LODLevel.REDUCED
+ : LODLevel.MINIMAL
+ return LOD_CONFIGS[level][feature] as boolean
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue
new file mode 100644
index 0000000000..ae8fb75674
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue
@@ -0,0 +1,43 @@
+
+
+ {{
+ widget.name
+ }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue
new file mode 100644
index 0000000000..1c40d35a7f
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetChart.vue
@@ -0,0 +1,78 @@
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue
new file mode 100644
index 0000000000..ed5f2b0ecc
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue
@@ -0,0 +1,63 @@
+
+
+
+
+
+ #{{ localValue }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue
new file mode 100644
index 0000000000..bdbc427391
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue
@@ -0,0 +1,318 @@
+
+
+
+
+
+
+
{{ widget.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ widget.name }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ selectedFile?.name }}
+
+ {{
+ selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
+ }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t('Drop your file or') }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue
new file mode 100644
index 0000000000..3603b7ab67
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetGalleria.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue
new file mode 100644
index 0000000000..e51413a305
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetImageCompare.vue
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue
new file mode 100644
index 0000000000..a515ec7cdc
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue
new file mode 100644
index 0000000000..4749e561cd
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMultiSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetMultiSelect.vue
new file mode 100644
index 0000000000..c2ee71acfc
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMultiSelect.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
new file mode 100644
index 0000000000..d2021f6176
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue
new file mode 100644
index 0000000000..c63edcf60d
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue
new file mode 100644
index 0000000000..5bb53784f4
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
new file mode 100644
index 0000000000..157da563ed
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue
new file mode 100644
index 0000000000..61b29cba73
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTreeSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTreeSelect.vue
new file mode 100644
index 0000000000..81f7a117f2
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTreeSelect.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue b/src/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue
new file mode 100644
index 0000000000..41e5f6efa8
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/form/FormSelectButton.vue
@@ -0,0 +1,108 @@
+
+
+
+ {{ getOptionLabel(option) }}
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue
new file mode 100644
index 0000000000..3aac1b2426
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ {{ widget.name }}
+
+
+
+
+
+
diff --git a/src/renderer/extensions/vueNodes/widgets/components/layout/index.ts b/src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
new file mode 100644
index 0000000000..5de9f97aaf
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/components/layout/index.ts
@@ -0,0 +1,14 @@
+export const WidgetInputBaseClass = [
+ // Background
+ 'bg-zinc-500/10',
+ // Outline
+ 'border-none',
+ 'outline',
+ 'outline-1',
+ 'outline-offset-[-1px]',
+ 'outline-zinc-300/10',
+ // Rounded
+ '!rounded-lg',
+ // Hover
+ 'hover:outline-blue-500/80'
+].join(' ')
diff --git a/src/composables/widgets/useBooleanWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget.ts
similarity index 100%
rename from src/composables/widgets/useBooleanWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useChartWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useChartWidget.ts
new file mode 100644
index 0000000000..4c94155a68
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useChartWidget.ts
@@ -0,0 +1,28 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IChartWidget } from '@/lib/litegraph/src/types/widgets'
+import {
+ type ChartInputSpec,
+ type InputSpec as InputSpecV2,
+ isChartInputSpec
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useChartWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IChartWidget => {
+ if (!isChartInputSpec(inputSpec)) {
+ throw new Error('Invalid input spec for chart widget')
+ }
+
+ const { name, options = {} } = inputSpec as ChartInputSpec
+
+ const chartType = options.type || 'line'
+
+ const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
+ serialize: true,
+ type: chartType,
+ ...options
+ }) as IChartWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useChatHistoryWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget.ts
similarity index 100%
rename from src/composables/widgets/useChatHistoryWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts
new file mode 100644
index 0000000000..420e31ce05
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useColorWidget.ts
@@ -0,0 +1,20 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IColorWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ ColorInputSpec,
+ InputSpec as InputSpecV2
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useColorWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
+ const { name, options } = inputSpec as ColorInputSpec
+ const defaultValue = options?.default || '#000000'
+
+ const widget = node.addWidget('color', name, defaultValue, () => {}, {
+ serialize: true
+ }) as IColorWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useComboWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
similarity index 100%
rename from src/composables/widgets/useComboWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useFileUploadWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useFileUploadWidget.ts
new file mode 100644
index 0000000000..b6c61753fd
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useFileUploadWidget.ts
@@ -0,0 +1,20 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IFileUploadWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ FileUploadInputSpec,
+ InputSpec as InputSpecV2
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
+ const { name, options = {} } = inputSpec as FileUploadInputSpec
+
+ const widget = node.addWidget('fileupload', name, '', () => {}, {
+ serialize: true,
+ ...(options as Record)
+ }) as IFileUploadWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useFloatWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts
similarity index 97%
rename from src/composables/widgets/useFloatWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts
index f21a1a7c47..c9017ea6c2 100644
--- a/src/composables/widgets/useFloatWidget.ts
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.ts
@@ -1,4 +1,4 @@
-import _ from 'es-toolkit/compat'
+import { clamp } from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
@@ -15,7 +15,7 @@ function onFloatValueChange(this: INumericWidget, v: number) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
- this.value = _.clamp(
+ this.value = clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget.ts
new file mode 100644
index 0000000000..e06259d29e
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget.ts
@@ -0,0 +1,26 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IGalleriaWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ GalleriaInputSpec,
+ InputSpec as InputSpecV2
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
+ const { name, options = {} } = inputSpec as GalleriaInputSpec
+
+ const widget = node.addWidget(
+ 'galleria',
+ name,
+ options.images || [],
+ () => {},
+ {
+ serialize: true,
+ ...options
+ }
+ ) as IGalleriaWidget
+
+ return widget
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts
new file mode 100644
index 0000000000..a985585e5a
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget.ts
@@ -0,0 +1,20 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IImageCompareWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ ImageCompareInputSpec,
+ InputSpec as InputSpecV2
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useImageCompareWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IImageCompareWidget => {
+ const { name, options = {} } = inputSpec as ImageCompareInputSpec
+
+ const widget = node.addWidget('imagecompare', name, ['', ''], () => {}, {
+ serialize: true,
+ ...options
+ }) as IImageCompareWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useImagePreviewWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts
similarity index 100%
rename from src/composables/widgets/useImagePreviewWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget.ts
diff --git a/src/composables/widgets/useImageUploadWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts
similarity index 100%
rename from src/composables/widgets/useImageUploadWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget.ts
diff --git a/src/composables/widgets/useIntWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts
similarity index 100%
rename from src/composables/widgets/useIntWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useIntWidget.ts
diff --git a/src/composables/widgets/useMarkdownWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts
similarity index 100%
rename from src/composables/widgets/useMarkdownWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useMultiSelectWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useMultiSelectWidget.ts
new file mode 100644
index 0000000000..2730115b7e
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useMultiSelectWidget.ts
@@ -0,0 +1,21 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { IMultiSelectWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ InputSpec as InputSpecV2,
+ MultiSelectInputSpec
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
+ const { name, options = {} } = inputSpec as MultiSelectInputSpec
+
+ const widget = node.addWidget('multiselect', name, [], () => {}, {
+ serialize: true,
+ values: options.values || [],
+ ...options
+ }) as IMultiSelectWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useProgressTextWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget.ts
similarity index 100%
rename from src/composables/widgets/useProgressTextWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget.ts
diff --git a/src/composables/widgets/useRemoteWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
similarity index 100%
rename from src/composables/widgets/useRemoteWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useSelectButtonWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useSelectButtonWidget.ts
new file mode 100644
index 0000000000..f9c3af9aae
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useSelectButtonWidget.ts
@@ -0,0 +1,28 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { ISelectButtonWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ InputSpec as InputSpecV2,
+ SelectButtonInputSpec
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
+ const { name, options = {} } = inputSpec as SelectButtonInputSpec
+ const values = options.values || []
+
+ const widget = node.addWidget(
+ 'selectbutton',
+ name,
+ values[0] || '',
+ (_value: string) => {},
+ {
+ serialize: true,
+ values,
+ ...options
+ }
+ ) as ISelectButtonWidget
+
+ return widget
+ }
+}
diff --git a/src/composables/widgets/useStringWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts
similarity index 100%
rename from src/composables/widgets/useStringWidget.ts
rename to src/renderer/extensions/vueNodes/widgets/composables/useStringWidget.ts
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget.ts
new file mode 100644
index 0000000000..052c8935c7
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget.ts
@@ -0,0 +1,28 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { ITextareaWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ InputSpec as InputSpecV2,
+ TextareaInputSpec
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
+ const { name, options = {} } = inputSpec as TextareaInputSpec
+
+ const widget = node.addWidget(
+ 'textarea',
+ name,
+ options.default || '',
+ () => {},
+ {
+ serialize: true,
+ rows: options.rows || 5,
+ cols: options.cols || 50,
+ ...options
+ }
+ ) as ITextareaWidget
+
+ return widget
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useTreeSelectWidget.ts b/src/renderer/extensions/vueNodes/widgets/composables/useTreeSelectWidget.ts
new file mode 100644
index 0000000000..b497e9ae0e
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/composables/useTreeSelectWidget.ts
@@ -0,0 +1,24 @@
+import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
+import type { ITreeSelectWidget } from '@/lib/litegraph/src/types/widgets'
+import type {
+ InputSpec as InputSpecV2,
+ TreeSelectInputSpec
+} from '@/schemas/nodeDef/nodeDefSchemaV2'
+import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
+
+export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
+ return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
+ const { name, options = {} } = inputSpec as TreeSelectInputSpec
+ const isMultiple = options.multiple || false
+ const defaultValue = isMultiple ? [] : ''
+
+ const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
+ serialize: true,
+ values: options.values || [],
+ multiple: isMultiple,
+ ...options
+ }) as ITreeSelectWidget
+
+ return widget
+ }
+}
diff --git a/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
new file mode 100644
index 0000000000..e72d90be15
--- /dev/null
+++ b/src/renderer/extensions/vueNodes/widgets/registry/widgetRegistry.ts
@@ -0,0 +1,150 @@
+/**
+ * Widget type registry and component mapping for Vue-based widgets
+ */
+import type { Component } from 'vue'
+
+import WidgetButton from '../components/WidgetButton.vue'
+import WidgetChart from '../components/WidgetChart.vue'
+import WidgetColorPicker from '../components/WidgetColorPicker.vue'
+import WidgetFileUpload from '../components/WidgetFileUpload.vue'
+import WidgetGalleria from '../components/WidgetGalleria.vue'
+import WidgetImageCompare from '../components/WidgetImageCompare.vue'
+import WidgetInputText from '../components/WidgetInputText.vue'
+import WidgetMarkdown from '../components/WidgetMarkdown.vue'
+import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
+import WidgetSelect from '../components/WidgetSelect.vue'
+import WidgetSelectButton from '../components/WidgetSelectButton.vue'
+import WidgetSlider from '../components/WidgetSlider.vue'
+import WidgetTextarea from '../components/WidgetTextarea.vue'
+import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
+import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
+
+interface WidgetDefinition {
+ component: Component
+ aliases: string[]
+ essential: boolean
+}
+
+const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
+ [
+ 'button',
+ { component: WidgetButton, aliases: ['BUTTON'], essential: false }
+ ],
+ [
+ 'string',
+ {
+ component: WidgetInputText,
+ aliases: ['STRING', 'text'],
+ essential: false
+ }
+ ],
+ ['int', { component: WidgetSlider, aliases: ['INT'], essential: true }],
+ [
+ 'float',
+ {
+ component: WidgetSlider,
+ aliases: ['FLOAT', 'number', 'slider'],
+ essential: true
+ }
+ ],
+ [
+ 'boolean',
+ {
+ component: WidgetToggleSwitch,
+ aliases: ['BOOLEAN', 'toggle'],
+ essential: true
+ }
+ ],
+ ['combo', { component: WidgetSelect, aliases: ['COMBO'], essential: true }],
+ [
+ 'color',
+ { component: WidgetColorPicker, aliases: ['COLOR'], essential: false }
+ ],
+ [
+ 'multiselect',
+ { component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
+ ],
+ [
+ 'selectbutton',
+ {
+ component: WidgetSelectButton,
+ aliases: ['SELECTBUTTON'],
+ essential: false
+ }
+ ],
+ [
+ 'textarea',
+ {
+ component: WidgetTextarea,
+ aliases: ['TEXTAREA', 'multiline', 'customtext'],
+ essential: false
+ }
+ ],
+ ['chart', { component: WidgetChart, aliases: ['CHART'], essential: false }],
+ [
+ 'imagecompare',
+ {
+ component: WidgetImageCompare,
+ aliases: ['IMAGECOMPARE'],
+ essential: false
+ }
+ ],
+ [
+ 'galleria',
+ { component: WidgetGalleria, aliases: ['GALLERIA'], essential: false }
+ ],
+ [
+ 'fileupload',
+ {
+ component: WidgetFileUpload,
+ aliases: ['FILEUPLOAD', 'file'],
+ essential: false
+ }
+ ],
+ [
+ 'treeselect',
+ { component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
+ ],
+ [
+ 'markdown',
+ { component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
+ ]
+]
+
+// Build lookup maps
+const widgets = new Map()
+const aliasMap = new Map()
+
+for (const [type, def] of coreWidgetDefinitions) {
+ widgets.set(type, def)
+ for (const alias of def.aliases) {
+ aliasMap.set(alias, type)
+ }
+}
+
+// Utility functions
+const getCanonicalType = (type: string): string => aliasMap.get(type) || type
+
+export const getComponent = (type: string): Component | null => {
+ const canonicalType = getCanonicalType(type)
+ return widgets.get(canonicalType)?.component || null
+}
+
+export const isSupported = (type: string): boolean => {
+ const canonicalType = getCanonicalType(type)
+ return widgets.has(canonicalType)
+}
+
+export const isEssential = (type: string): boolean => {
+ const canonicalType = getCanonicalType(type)
+ return widgets.get(canonicalType)?.essential || false
+}
+
+export const shouldRenderAsVue = (widget: {
+ type?: string
+ options?: Record
+}): boolean => {
+ if (widget.options?.canvasOnly) return false
+ if (!widget.type) return false
+ return isSupported(widget.type)
+}
diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts
index 145ee57a0e..8744496659 100644
--- a/src/schemas/apiSchema.ts
+++ b/src/schemas/apiSchema.ts
@@ -478,6 +478,7 @@ const zSettings = z.object({
'Comfy.Minimap.RenderBypassState': z.boolean(),
'Comfy.Minimap.RenderErrorState': z.boolean(),
'Comfy.Canvas.NavigationMode': z.string(),
+ 'Comfy.VueNodes.Enabled': z.boolean(),
'Comfy-Desktop.AutoUpdate': z.boolean(),
'Comfy-Desktop.SendStatistics': z.boolean(),
'Comfy-Desktop.WindowStyle': z.string(),
diff --git a/src/schemas/nodeDef/nodeDefSchemaV2.ts b/src/schemas/nodeDef/nodeDefSchemaV2.ts
index a5594953dc..f94d7cc486 100644
--- a/src/schemas/nodeDef/nodeDefSchemaV2.ts
+++ b/src/schemas/nodeDef/nodeDefSchemaV2.ts
@@ -39,6 +39,119 @@ const zComboInputSpec = zComboInputOptions.extend({
isOptional: z.boolean().optional()
})
+const zColorInputSpec = zBaseInputOptions.extend({
+ type: z.literal('COLOR'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ default: z.string().optional()
+ })
+ .optional()
+})
+
+const zFileUploadInputSpec = zBaseInputOptions.extend({
+ type: z.literal('FILEUPLOAD'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z.record(z.unknown()).optional()
+})
+
+const zImageInputSpec = zBaseInputOptions.extend({
+ type: z.literal('IMAGE'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z.record(z.unknown()).optional()
+})
+
+const zImageCompareInputSpec = zBaseInputOptions.extend({
+ type: z.literal('IMAGECOMPARE'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z.record(z.unknown()).optional()
+})
+
+const zMarkdownInputSpec = zBaseInputOptions.extend({
+ type: z.literal('MARKDOWN'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ content: z.string().optional()
+ })
+ .optional()
+})
+
+const zTreeSelectInputSpec = zBaseInputOptions.extend({
+ type: z.literal('TREESELECT'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ multiple: z.boolean().optional(),
+ values: z.array(z.unknown()).optional()
+ })
+ .optional()
+})
+
+const zMultiSelectInputSpec = zBaseInputOptions.extend({
+ type: z.literal('MULTISELECT'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ values: z.array(z.string()).optional()
+ })
+ .optional()
+})
+
+const zChartInputSpec = zBaseInputOptions.extend({
+ type: z.literal('CHART'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ type: z.enum(['bar', 'line']).optional(),
+ data: z.object({}).optional()
+ })
+ .optional()
+})
+
+const zGalleriaInputSpec = zBaseInputOptions.extend({
+ type: z.literal('GALLERIA'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ images: z.array(z.string()).optional()
+ })
+ .optional()
+})
+
+const zSelectButtonInputSpec = zBaseInputOptions.extend({
+ type: z.literal('SELECTBUTTON'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ values: z.array(z.string()).optional()
+ })
+ .optional()
+})
+
+const zTextareaInputSpec = zBaseInputOptions.extend({
+ type: z.literal('TEXTAREA'),
+ name: z.string(),
+ isOptional: z.boolean().optional(),
+ options: z
+ .object({
+ rows: z.number().optional(),
+ cols: z.number().optional(),
+ default: z.string().optional()
+ })
+ .optional()
+})
+
const zCustomInputSpec = zBaseInputOptions.extend({
type: z.string(),
name: z.string(),
@@ -51,6 +164,17 @@ const zInputSpec = z.union([
zBooleanInputSpec,
zStringInputSpec,
zComboInputSpec,
+ zColorInputSpec,
+ zFileUploadInputSpec,
+ zImageInputSpec,
+ zImageCompareInputSpec,
+ zMarkdownInputSpec,
+ zTreeSelectInputSpec,
+ zMultiSelectInputSpec,
+ zChartInputSpec,
+ zGalleriaInputSpec,
+ zSelectButtonInputSpec,
+ zTextareaInputSpec,
zCustomInputSpec
])
@@ -88,6 +212,17 @@ export type FloatInputSpec = z.infer
export type BooleanInputSpec = z.infer
export type StringInputSpec = z.infer
export type ComboInputSpec = z.infer
+export type ColorInputSpec = z.infer
+export type FileUploadInputSpec = z.infer
+export type ImageInputSpec = z.infer
+export type ImageCompareInputSpec = z.infer
+export type MarkdownInputSpec = z.infer
+export type TreeSelectInputSpec = z.infer
+export type MultiSelectInputSpec = z.infer
+export type ChartInputSpec = z.infer
+export type GalleriaInputSpec = z.infer
+export type SelectButtonInputSpec = z.infer
+export type TextareaInputSpec = z.infer
export type CustomInputSpec = z.infer
export type InputSpec = z.infer
@@ -124,18 +259,8 @@ export const isComboInputSpec = (
return inputSpec.type === 'COMBO'
}
-/**
- * Check if a node definition is a valid ComfyUI node definition.
- *
- * Note: This is just a simple check against the V1 schema.
- *
- * @param nodeDef - The node definition to check.
- * @returns True if the node definition is valid, false otherwise.
- */
-export const isComfyNodeDef = (nodeDef: unknown): nodeDef is ComfyNodeDef => {
- return (
- !!nodeDef &&
- typeof nodeDef === 'object' &&
- ['inputs', 'outputs'].every((key) => key in nodeDef)
- )
+export const isChartInputSpec = (
+ inputSpec: InputSpec
+): inputSpec is ChartInputSpec => {
+ return inputSpec.type === 'CHART'
}
diff --git a/src/scripts/errorNodeWidgets.ts b/src/scripts/errorNodeWidgets.ts
index 71a7ddf1b6..f6d1144639 100644
--- a/src/scripts/errorNodeWidgets.ts
+++ b/src/scripts/errorNodeWidgets.ts
@@ -1,9 +1,9 @@
import { useChainCallback } from '@/composables/functional/useChainCallback'
-import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
-import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
-import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
+import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
+import { useFloatWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
+import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
const StringWidget = useStringWidget()
const FloatWidget = useFloatWidget()
diff --git a/src/scripts/widgets.ts b/src/scripts/widgets.ts
index b0b607917d..357dc2bf15 100644
--- a/src/scripts/widgets.ts
+++ b/src/scripts/widgets.ts
@@ -1,10 +1,3 @@
-import { useBooleanWidget } from '@/composables/widgets/useBooleanWidget'
-import { useComboWidget } from '@/composables/widgets/useComboWidget'
-import { useFloatWidget } from '@/composables/widgets/useFloatWidget'
-import { useImageUploadWidget } from '@/composables/widgets/useImageUploadWidget'
-import { useIntWidget } from '@/composables/widgets/useIntWidget'
-import { useMarkdownWidget } from '@/composables/widgets/useMarkdownWidget'
-import { useStringWidget } from '@/composables/widgets/useStringWidget'
import { t } from '@/i18n'
import { type LGraphNode, isComboWidget } from '@/lib/litegraph/src/litegraph'
import type {
@@ -12,6 +5,22 @@ import type {
IComboWidget,
IStringWidget
} from '@/lib/litegraph/src/types/widgets'
+import { useBooleanWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useBooleanWidget'
+import { useChartWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChartWidget'
+import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
+import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
+import { useFileUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFileUploadWidget'
+import { useFloatWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
+import { useGalleriaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useGalleriaWidget'
+import { useImageCompareWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageCompareWidget'
+import { useImageUploadWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImageUploadWidget'
+import { useIntWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
+import { useMarkdownWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMarkdownWidget'
+import { useMultiSelectWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useMultiSelectWidget'
+import { useSelectButtonWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useSelectButtonWidget'
+import { useStringWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useStringWidget'
+import { useTextareaWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTextareaWidget'
+import { useTreeSelectWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useTreeSelectWidget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -288,5 +297,14 @@ export const ComfyWidgets: Record = {
STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
- IMAGEUPLOAD: useImageUploadWidget()
+ IMAGEUPLOAD: useImageUploadWidget(),
+ FILEUPLOAD: transformWidgetConstructorV2ToV1(useFileUploadWidget()),
+ COLOR: transformWidgetConstructorV2ToV1(useColorWidget()),
+ IMAGECOMPARE: transformWidgetConstructorV2ToV1(useImageCompareWidget()),
+ TREESELECT: transformWidgetConstructorV2ToV1(useTreeSelectWidget()),
+ MULTISELECT: transformWidgetConstructorV2ToV1(useMultiSelectWidget()),
+ CHART: transformWidgetConstructorV2ToV1(useChartWidget()),
+ GALLERIA: transformWidgetConstructorV2ToV1(useGalleriaWidget()),
+ SELECTBUTTON: transformWidgetConstructorV2ToV1(useSelectButtonWidget()),
+ TEXTAREA: transformWidgetConstructorV2ToV1(useTextareaWidget())
}
diff --git a/src/stores/managerStateStore.ts b/src/stores/managerStateStore.ts
index 2dece29a7c..6ba37e80d2 100644
--- a/src/stores/managerStateStore.ts
+++ b/src/stores/managerStateStore.ts
@@ -28,14 +28,6 @@ export const useManagerStateStore = defineStore('managerState', () => {
'extension.manager.supports_v4'
)
- console.log('[Manager State Debug]', {
- systemStats: systemStats?.system?.argv,
- clientSupportsV4,
- serverSupportsV4,
- hasLegacyManager,
- extensions: extensionStore.extensions.map((e) => e.name)
- })
-
// Check command line args first
if (systemStats?.system?.argv?.includes('--disable-manager')) {
return ManagerUIState.DISABLED // comfyui_manager package not installed
diff --git a/src/types/simplifiedWidget.ts b/src/types/simplifiedWidget.ts
new file mode 100644
index 0000000000..27be67f11a
--- /dev/null
+++ b/src/types/simplifiedWidget.ts
@@ -0,0 +1,41 @@
+/**
+ * Simplified widget interface for Vue-based node rendering
+ * Removes all DOM manipulation and positioning concerns
+ */
+
+/** Valid types for widget values */
+export type WidgetValue =
+ | string
+ | number
+ | boolean
+ | object
+ | undefined
+ | null
+ | void
+ | File[]
+
+export interface SimplifiedWidget<
+ T extends WidgetValue = WidgetValue,
+ O = Record
+> {
+ /** Display name of the widget */
+ name: string
+
+ /** Widget type identifier (e.g., 'STRING', 'INT', 'COMBO') */
+ type: string
+
+ /** Current value of the widget */
+ value: T
+
+ /** Widget options including filtered PrimeVue props */
+ options?: O
+
+ /** Callback fired when value changes */
+ callback?: (value: T) => void
+
+ /** Optional serialization method for custom value handling */
+ serializeValue?: () => any
+
+ /** Optional method to compute widget size requirements */
+ computeSize?: () => { minHeight: number; maxHeight?: number }
+}
diff --git a/src/types/spatialIndex.ts b/src/types/spatialIndex.ts
new file mode 100644
index 0000000000..fb4250ba13
--- /dev/null
+++ b/src/types/spatialIndex.ts
@@ -0,0 +1,23 @@
+/**
+ * Type definitions for spatial indexing system
+ */
+import type { Bounds } from '@/utils/spatial/QuadTree'
+
+/**
+ * Debug information for a single QuadTree node
+ */
+export interface QuadNodeDebugInfo {
+ bounds: Bounds
+ depth: number
+ itemCount: number
+ divided: boolean
+ children?: QuadNodeDebugInfo[]
+}
+
+/**
+ * Debug information for the entire spatial index
+ */
+export interface SpatialIndexDebugInfo {
+ size: number
+ tree: QuadNodeDebugInfo
+}
diff --git a/src/utils/spatial/QuadTree.ts b/src/utils/spatial/QuadTree.ts
new file mode 100644
index 0000000000..e2c90b32e3
--- /dev/null
+++ b/src/utils/spatial/QuadTree.ts
@@ -0,0 +1,302 @@
+/**
+ * QuadTree implementation for spatial indexing of nodes
+ * Optimized for viewport culling in large node graphs
+ */
+import type {
+ QuadNodeDebugInfo,
+ SpatialIndexDebugInfo
+} from '@/types/spatialIndex'
+
+export interface Bounds {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
+export interface QuadTreeItem {
+ id: string
+ bounds: Bounds
+ data: T
+}
+
+interface QuadTreeOptions {
+ maxDepth?: number
+ maxItemsPerNode?: number
+ minNodeSize?: number
+}
+
+class QuadNode {
+ private bounds: Bounds
+ private depth: number
+ private maxDepth: number
+ private maxItems: number
+ private items: QuadTreeItem[] = []
+ private children: QuadNode[] | null = null
+ private divided = false
+
+ constructor(
+ bounds: Bounds,
+ depth: number = 0,
+ maxDepth: number = 5,
+ maxItems: number = 4
+ ) {
+ this.bounds = bounds
+ this.depth = depth
+ this.maxDepth = maxDepth
+ this.maxItems = maxItems
+ }
+
+ insert(item: QuadTreeItem): boolean {
+ // Check if item is within bounds
+ if (!this.contains(item.bounds)) {
+ return false
+ }
+
+ // If we have space and haven't divided, add to this node
+ if (this.items.length < this.maxItems && !this.divided) {
+ this.items.push(item)
+ return true
+ }
+
+ // If we haven't reached max depth, subdivide
+ if (!this.divided && this.depth < this.maxDepth) {
+ this.subdivide()
+ }
+
+ // If divided, insert into children
+ if (this.divided && this.children) {
+ for (const child of this.children) {
+ if (child.insert(item)) {
+ return true
+ }
+ }
+ }
+
+ // If we can't subdivide further, add to this node anyway
+ this.items.push(item)
+ return true
+ }
+
+ remove(item: QuadTreeItem): boolean {
+ const index = this.items.findIndex((i) => i.id === item.id)
+ if (index !== -1) {
+ this.items.splice(index, 1)
+ return true
+ }
+
+ if (this.divided && this.children) {
+ for (const child of this.children) {
+ if (child.remove(item)) {
+ return true
+ }
+ }
+ }
+
+ return false
+ }
+
+ query(
+ searchBounds: Bounds,
+ found: QuadTreeItem[] = []
+ ): QuadTreeItem[] {
+ // Check if search area intersects with this node
+ if (!this.intersects(searchBounds)) {
+ return found
+ }
+
+ // Add items in this node that intersect with search bounds
+ for (const item of this.items) {
+ if (this.boundsIntersect(item.bounds, searchBounds)) {
+ found.push(item)
+ }
+ }
+
+ // Recursively search children
+ if (this.divided && this.children) {
+ for (const child of this.children) {
+ child.query(searchBounds, found)
+ }
+ }
+
+ return found
+ }
+
+ private subdivide() {
+ const { x, y, width, height } = this.bounds
+ const halfWidth = width / 2
+ const halfHeight = height / 2
+
+ this.children = [
+ // Top-left
+ new QuadNode(
+ { x, y, width: halfWidth, height: halfHeight },
+ this.depth + 1,
+ this.maxDepth,
+ this.maxItems
+ ),
+ // Top-right
+ new QuadNode(
+ { x: x + halfWidth, y, width: halfWidth, height: halfHeight },
+ this.depth + 1,
+ this.maxDepth,
+ this.maxItems
+ ),
+ // Bottom-left
+ new QuadNode(
+ { x, y: y + halfHeight, width: halfWidth, height: halfHeight },
+ this.depth + 1,
+ this.maxDepth,
+ this.maxItems
+ ),
+ // Bottom-right
+ new QuadNode(
+ {
+ x: x + halfWidth,
+ y: y + halfHeight,
+ width: halfWidth,
+ height: halfHeight
+ },
+ this.depth + 1,
+ this.maxDepth,
+ this.maxItems
+ )
+ ]
+
+ this.divided = true
+
+ // Redistribute existing items to children
+ const itemsToRedistribute = [...this.items]
+ this.items = []
+
+ for (const item of itemsToRedistribute) {
+ let inserted = false
+ for (const child of this.children) {
+ if (child.insert(item)) {
+ inserted = true
+ break
+ }
+ }
+ // Keep in parent if it doesn't fit in any child
+ if (!inserted) {
+ this.items.push(item)
+ }
+ }
+ }
+
+ private contains(itemBounds: Bounds): boolean {
+ return (
+ itemBounds.x >= this.bounds.x &&
+ itemBounds.y >= this.bounds.y &&
+ itemBounds.x + itemBounds.width <= this.bounds.x + this.bounds.width &&
+ itemBounds.y + itemBounds.height <= this.bounds.y + this.bounds.height
+ )
+ }
+
+ private intersects(searchBounds: Bounds): boolean {
+ return this.boundsIntersect(this.bounds, searchBounds)
+ }
+
+ private boundsIntersect(a: Bounds, b: Bounds): boolean {
+ return !(
+ a.x + a.width < b.x ||
+ b.x + b.width < a.x ||
+ a.y + a.height < b.y ||
+ b.y + b.height < a.y
+ )
+ }
+
+ // Debug helper to get tree structure
+ getDebugInfo(): QuadNodeDebugInfo {
+ return {
+ bounds: this.bounds,
+ depth: this.depth,
+ itemCount: this.items.length,
+ divided: this.divided,
+ children: this.children?.map((child) => child.getDebugInfo())
+ }
+ }
+}
+
+export class QuadTree {
+ private root: QuadNode
+ private itemMap: Map> = new Map()
+ private options: Required
+
+ constructor(bounds: Bounds, options: QuadTreeOptions = {}) {
+ this.options = {
+ maxDepth: options.maxDepth ?? 5,
+ maxItemsPerNode: options.maxItemsPerNode ?? 4,
+ minNodeSize: options.minNodeSize ?? 50
+ }
+
+ this.root = new QuadNode(
+ bounds,
+ 0,
+ this.options.maxDepth,
+ this.options.maxItemsPerNode
+ )
+ }
+
+ insert(id: string, bounds: Bounds, data: T): boolean {
+ const item: QuadTreeItem = { id, bounds, data }
+
+ // Remove old item if it exists
+ if (this.itemMap.has(id)) {
+ this.remove(id)
+ }
+
+ const success = this.root.insert(item)
+ if (success) {
+ this.itemMap.set(id, item)
+ }
+ return success
+ }
+
+ remove(id: string): boolean {
+ const item = this.itemMap.get(id)
+ if (!item) return false
+
+ const success = this.root.remove(item)
+ if (success) {
+ this.itemMap.delete(id)
+ }
+ return success
+ }
+
+ update(id: string, newBounds: Bounds): boolean {
+ const item = this.itemMap.get(id)
+ if (!item) return false
+
+ // Remove and re-insert with new bounds
+ const data = item.data
+ this.remove(id)
+ return this.insert(id, newBounds, data)
+ }
+
+ query(searchBounds: Bounds): T[] {
+ const items = this.root.query(searchBounds)
+ return items.map((item) => item.data)
+ }
+
+ clear() {
+ this.root = new QuadNode(
+ this.root['bounds'],
+ 0,
+ this.options.maxDepth,
+ this.options.maxItemsPerNode
+ )
+ this.itemMap.clear()
+ }
+
+ get size(): number {
+ return this.itemMap.size
+ }
+
+ getDebugInfo(): SpatialIndexDebugInfo {
+ return {
+ size: this.size,
+ tree: this.root.getDebugInfo()
+ }
+ }
+}
diff --git a/src/utils/tailwindUtil.ts b/src/utils/tailwindUtil.ts
new file mode 100644
index 0000000000..39124c7684
--- /dev/null
+++ b/src/utils/tailwindUtil.ts
@@ -0,0 +1,8 @@
+import clsx, { type ClassArray } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export type { ClassValue, ClassArray, ClassDictionary } from 'clsx'
+
+export function cn(...inputs: ClassArray) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/utils/typeGuardUtil.ts b/src/utils/typeGuardUtil.ts
index af88ec55ef..decf35554b 100644
--- a/src/utils/typeGuardUtil.ts
+++ b/src/utils/typeGuardUtil.ts
@@ -1,5 +1,9 @@
import type { PrimitiveNode } from '@/extensions/core/widgetInputs'
-import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
+import {
+ type INodeSlot,
+ LGraph,
+ LGraphNode
+} from '@/lib/litegraph/src/litegraph'
import { Subgraph } from '@/lib/litegraph/src/litegraph'
export function isPrimitiveNode(
@@ -39,3 +43,16 @@ export const isSubgraphIoNode = (
const nodeClass = node.constructor?.comfyClass
return nodeClass === 'SubgraphInputNode' || nodeClass === 'SubgraphOutputNode'
}
+
+/**
+ * Type guard for slot objects (inputs/outputs)
+ */
+export const isSlotObject = (obj: unknown): obj is INodeSlot => {
+ return (
+ obj !== null &&
+ typeof obj === 'object' &&
+ 'name' in obj &&
+ 'type' in obj &&
+ 'boundingRect' in obj
+ )
+}
diff --git a/src/utils/widgetPropFilter.ts b/src/utils/widgetPropFilter.ts
new file mode 100644
index 0000000000..af2bac794f
--- /dev/null
+++ b/src/utils/widgetPropFilter.ts
@@ -0,0 +1,71 @@
+/**
+ * Widget prop filtering utilities
+ * Filters out style-related and customization props from PrimeVue components
+ * to maintain consistent widget appearance across the application
+ */
+
+// Props to exclude based on the widget interface specifications
+export const STANDARD_EXCLUDED_PROPS = [
+ 'style',
+ 'class',
+ 'dt',
+ 'pt',
+ 'ptOptions',
+ 'unstyled'
+] as const
+
+export const INPUT_EXCLUDED_PROPS = [
+ ...STANDARD_EXCLUDED_PROPS,
+ 'inputClass',
+ 'inputStyle'
+] as const
+
+export const PANEL_EXCLUDED_PROPS = [
+ ...STANDARD_EXCLUDED_PROPS,
+ 'panelClass',
+ 'panelStyle',
+ 'overlayClass'
+] as const
+
+// export const IMAGE_EXCLUDED_PROPS = [
+// ...STANDARD_EXCLUDED_PROPS,
+// 'imageClass',
+// 'imageStyle'
+// ] as const
+
+export const GALLERIA_EXCLUDED_PROPS = [
+ ...STANDARD_EXCLUDED_PROPS,
+ 'thumbnailsPosition',
+ 'verticalThumbnailViewPortHeight',
+ 'indicatorsPosition',
+ 'maskClass',
+ 'containerStyle',
+ 'containerClass',
+ 'galleriaClass'
+] as const
+
+export const BADGE_EXCLUDED_PROPS = [
+ ...STANDARD_EXCLUDED_PROPS,
+ 'badgeClass'
+] as const
+
+/**
+ * Filters widget props by excluding specified properties
+ * @param props - The props object to filter
+ * @param excludeList - List of property names to exclude
+ * @returns Filtered props object
+ */
+export function filterWidgetProps>(
+ props: T | undefined,
+ excludeList: readonly string[]
+): Partial {
+ if (!props) return {}
+
+ const filtered: Record = {}
+ for (const [key, value] of Object.entries(props)) {
+ if (!excludeList.includes(key)) {
+ filtered[key] = value
+ }
+ }
+ return filtered as Partial
+}
diff --git a/tailwind.config.ts b/tailwind.config.ts
index cbf3ce996d..1544c6759b 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -67,6 +67,7 @@ export default {
60: '15rem',
64: '16rem',
72: '18rem',
+ 75: '18.75rem',
80: '20rem',
84: '22rem',
90: '24rem',
diff --git a/tests-ui/tests/composables/element/useTransformState.test.ts b/tests-ui/tests/composables/element/useTransformState.test.ts
new file mode 100644
index 0000000000..ad250872fe
--- /dev/null
+++ b/tests-ui/tests/composables/element/useTransformState.test.ts
@@ -0,0 +1,351 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { useTransformState } from '@/composables/element/useTransformState'
+
+// Create a mock canvas context for transform testing
+function createMockCanvasContext() {
+ return {
+ canvas: {
+ width: 1280,
+ height: 720,
+ getBoundingClientRect: () => ({
+ left: 0,
+ top: 0,
+ width: 1280,
+ height: 720,
+ right: 1280,
+ bottom: 720,
+ x: 0,
+ y: 0
+ })
+ },
+ ds: {
+ offset: [0, 0],
+ scale: 1
+ }
+ }
+}
+
+describe('useTransformState', () => {
+ let transformState: ReturnType
+
+ beforeEach(() => {
+ transformState = useTransformState()
+ })
+
+ describe('initial state', () => {
+ it('should initialize with default camera values', () => {
+ const { camera } = transformState
+ expect(camera.x).toBe(0)
+ expect(camera.y).toBe(0)
+ expect(camera.z).toBe(1)
+ })
+
+ it('should generate correct initial transform style', () => {
+ const { transformStyle } = transformState
+ expect(transformStyle.value).toEqual({
+ transform: 'scale(1) translate(0px, 0px)',
+ transformOrigin: '0 0'
+ })
+ })
+ })
+
+ describe('syncWithCanvas', () => {
+ it('should sync camera state with canvas transform', () => {
+ const { syncWithCanvas, camera } = transformState
+ const mockCanvas = createMockCanvasContext()
+
+ // Set mock canvas transform
+ mockCanvas.ds.offset = [100, 50]
+ mockCanvas.ds.scale = 2
+
+ syncWithCanvas(mockCanvas as any)
+
+ expect(camera.x).toBe(100)
+ expect(camera.y).toBe(50)
+ expect(camera.z).toBe(2)
+ })
+
+ it('should handle null canvas gracefully', () => {
+ const { syncWithCanvas, camera } = transformState
+
+ syncWithCanvas(null as any)
+
+ // Should remain at initial values
+ expect(camera.x).toBe(0)
+ expect(camera.y).toBe(0)
+ expect(camera.z).toBe(1)
+ })
+
+ it('should handle canvas without ds property', () => {
+ const { syncWithCanvas, camera } = transformState
+ const canvasWithoutDs = { canvas: {} }
+
+ syncWithCanvas(canvasWithoutDs as any)
+
+ // Should remain at initial values
+ expect(camera.x).toBe(0)
+ expect(camera.y).toBe(0)
+ expect(camera.z).toBe(1)
+ })
+
+ it('should update transform style after sync', () => {
+ const { syncWithCanvas, transformStyle } = transformState
+ const mockCanvas = createMockCanvasContext()
+
+ mockCanvas.ds.offset = [150, 75]
+ mockCanvas.ds.scale = 0.5
+
+ syncWithCanvas(mockCanvas as any)
+
+ expect(transformStyle.value).toEqual({
+ transform: 'scale(0.5) translate(150px, 75px)',
+ transformOrigin: '0 0'
+ })
+ })
+ })
+
+ describe('coordinate conversions', () => {
+ beforeEach(() => {
+ // Set up a known transform state
+ const mockCanvas = createMockCanvasContext()
+ mockCanvas.ds.offset = [100, 50]
+ mockCanvas.ds.scale = 2
+ transformState.syncWithCanvas(mockCanvas as any)
+ })
+
+ describe('canvasToScreen', () => {
+ it('should convert canvas coordinates to screen coordinates', () => {
+ const { canvasToScreen } = transformState
+
+ const canvasPoint = { x: 10, y: 20 }
+ const screenPoint = canvasToScreen(canvasPoint)
+
+ // screen = canvas * scale + offset
+ // x: 10 * 2 + 100 = 120
+ // y: 20 * 2 + 50 = 90
+ expect(screenPoint).toEqual({ x: 120, y: 90 })
+ })
+
+ it('should handle zero coordinates', () => {
+ const { canvasToScreen } = transformState
+
+ const screenPoint = canvasToScreen({ x: 0, y: 0 })
+ expect(screenPoint).toEqual({ x: 100, y: 50 })
+ })
+
+ it('should handle negative coordinates', () => {
+ const { canvasToScreen } = transformState
+
+ const screenPoint = canvasToScreen({ x: -10, y: -20 })
+ expect(screenPoint).toEqual({ x: 80, y: 10 })
+ })
+ })
+
+ describe('screenToCanvas', () => {
+ it('should convert screen coordinates to canvas coordinates', () => {
+ const { screenToCanvas } = transformState
+
+ const screenPoint = { x: 120, y: 90 }
+ const canvasPoint = screenToCanvas(screenPoint)
+
+ // canvas = (screen - offset) / scale
+ // x: (120 - 100) / 2 = 10
+ // y: (90 - 50) / 2 = 20
+ expect(canvasPoint).toEqual({ x: 10, y: 20 })
+ })
+
+ it('should be inverse of canvasToScreen', () => {
+ const { canvasToScreen, screenToCanvas } = transformState
+
+ const originalPoint = { x: 25, y: 35 }
+ const screenPoint = canvasToScreen(originalPoint)
+ const backToCanvas = screenToCanvas(screenPoint)
+
+ expect(backToCanvas.x).toBeCloseTo(originalPoint.x)
+ expect(backToCanvas.y).toBeCloseTo(originalPoint.y)
+ })
+ })
+ })
+
+ describe('getNodeScreenBounds', () => {
+ beforeEach(() => {
+ const mockCanvas = createMockCanvasContext()
+ mockCanvas.ds.offset = [100, 50]
+ mockCanvas.ds.scale = 2
+ transformState.syncWithCanvas(mockCanvas as any)
+ })
+
+ it('should calculate correct screen bounds for a node', () => {
+ const { getNodeScreenBounds } = transformState
+
+ const nodePos = [10, 20]
+ const nodeSize = [200, 100]
+ const bounds = getNodeScreenBounds(nodePos, nodeSize)
+
+ // Top-left: canvasToScreen(10, 20) = (120, 90)
+ // Width: 200 * 2 = 400
+ // Height: 100 * 2 = 200
+ expect(bounds.x).toBe(120)
+ expect(bounds.y).toBe(90)
+ expect(bounds.width).toBe(400)
+ expect(bounds.height).toBe(200)
+ })
+ })
+
+ describe('isNodeInViewport', () => {
+ beforeEach(() => {
+ const mockCanvas = createMockCanvasContext()
+ mockCanvas.ds.offset = [0, 0]
+ mockCanvas.ds.scale = 1
+ transformState.syncWithCanvas(mockCanvas as any)
+ })
+
+ const viewport = { width: 1000, height: 600 }
+
+ it('should return true for nodes inside viewport', () => {
+ const { isNodeInViewport } = transformState
+
+ const nodePos = [100, 100]
+ const nodeSize = [200, 100]
+
+ expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(true)
+ })
+
+ it('should return false for nodes completely outside viewport', () => {
+ const { isNodeInViewport } = transformState
+
+ // Node far to the right
+ expect(isNodeInViewport([2000, 100], [200, 100], viewport)).toBe(false)
+
+ // Node far to the left
+ expect(isNodeInViewport([-500, 100], [200, 100], viewport)).toBe(false)
+
+ // Node far below
+ expect(isNodeInViewport([100, 1000], [200, 100], viewport)).toBe(false)
+
+ // Node far above
+ expect(isNodeInViewport([100, -500], [200, 100], viewport)).toBe(false)
+ })
+
+ it('should return true for nodes partially in viewport with margin', () => {
+ const { isNodeInViewport } = transformState
+
+ // Node slightly outside but within margin
+ const nodePos = [-50, -50]
+ const nodeSize = [100, 100]
+
+ expect(isNodeInViewport(nodePos, nodeSize, viewport, 0.2)).toBe(true)
+ })
+
+ it('should return false for tiny nodes (size culling)', () => {
+ const { isNodeInViewport } = transformState
+
+ // Node is in viewport but too small
+ const nodePos = [100, 100]
+ const nodeSize = [3, 3] // Less than 4 pixels
+
+ expect(isNodeInViewport(nodePos, nodeSize, viewport)).toBe(false)
+ })
+
+ it('should adjust margin based on zoom level', () => {
+ const { isNodeInViewport, syncWithCanvas } = transformState
+ const mockCanvas = createMockCanvasContext()
+
+ // Test with very low zoom
+ mockCanvas.ds.scale = 0.05
+ syncWithCanvas(mockCanvas as any)
+
+ // Node at edge should still be visible due to increased margin
+ expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(true)
+
+ // Test with high zoom
+ mockCanvas.ds.scale = 4
+ syncWithCanvas(mockCanvas as any)
+
+ // Margin should be tighter
+ expect(isNodeInViewport([1100, 100], [200, 100], viewport)).toBe(false)
+ })
+ })
+
+ describe('getViewportBounds', () => {
+ beforeEach(() => {
+ const mockCanvas = createMockCanvasContext()
+ mockCanvas.ds.offset = [100, 50]
+ mockCanvas.ds.scale = 2
+ transformState.syncWithCanvas(mockCanvas as any)
+ })
+
+ it('should calculate viewport bounds in canvas coordinates', () => {
+ const { getViewportBounds } = transformState
+ const viewport = { width: 1000, height: 600 }
+
+ const bounds = getViewportBounds(viewport, 0.2)
+
+ // With 20% margin:
+ // marginX = 1000 * 0.2 = 200
+ // marginY = 600 * 0.2 = 120
+ // topLeft in screen: (-200, -120)
+ // bottomRight in screen: (1200, 720)
+
+ // Convert to canvas coordinates:
+ // topLeft: ((-200 - 100) / 2, (-120 - 50) / 2) = (-150, -85)
+ // bottomRight: ((1200 - 100) / 2, (720 - 50) / 2) = (550, 335)
+
+ expect(bounds.x).toBe(-150)
+ expect(bounds.y).toBe(-85)
+ expect(bounds.width).toBe(700) // 550 - (-150)
+ expect(bounds.height).toBe(420) // 335 - (-85)
+ })
+
+ it('should handle zero margin', () => {
+ const { getViewportBounds } = transformState
+ const viewport = { width: 1000, height: 600 }
+
+ const bounds = getViewportBounds(viewport, 0)
+
+ // No margin, so viewport bounds are exact
+ expect(bounds.x).toBe(-50) // (0 - 100) / 2
+ expect(bounds.y).toBe(-25) // (0 - 50) / 2
+ expect(bounds.width).toBe(500) // 1000 / 2
+ expect(bounds.height).toBe(300) // 600 / 2
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle extreme zoom levels', () => {
+ const { syncWithCanvas, canvasToScreen } = transformState
+ const mockCanvas = createMockCanvasContext()
+
+ // Very small zoom
+ mockCanvas.ds.scale = 0.001
+ syncWithCanvas(mockCanvas as any)
+
+ const point1 = canvasToScreen({ x: 1000, y: 1000 })
+ expect(point1.x).toBeCloseTo(1)
+ expect(point1.y).toBeCloseTo(1)
+
+ // Very large zoom
+ mockCanvas.ds.scale = 100
+ syncWithCanvas(mockCanvas as any)
+
+ const point2 = canvasToScreen({ x: 1, y: 1 })
+ expect(point2.x).toBe(100)
+ expect(point2.y).toBe(100)
+ })
+
+ it('should handle zero scale in screenToCanvas', () => {
+ const { syncWithCanvas, screenToCanvas } = transformState
+ const mockCanvas = createMockCanvasContext()
+
+ // Scale of 0 gets converted to 1 by || operator
+ mockCanvas.ds.scale = 0
+ syncWithCanvas(mockCanvas as any)
+
+ // Should use scale of 1 due to camera.z || 1 in implementation
+ const result = screenToCanvas({ x: 100, y: 100 })
+ expect(result.x).toBe(100) // (100 - 0) / 1
+ expect(result.y).toBe(100) // (100 - 0) / 1
+ })
+ })
+})
diff --git a/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
new file mode 100644
index 0000000000..79503bb5f2
--- /dev/null
+++ b/tests-ui/tests/composables/graph/useCanvasTransformSync.test.ts
@@ -0,0 +1,240 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+
+import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
+
+import type { LGraphCanvas } from '../../../../src/lib/litegraph/src/litegraph'
+
+// Mock LiteGraph canvas
+const createMockCanvas = (): Partial => ({
+ canvas: document.createElement('canvas'),
+ ds: {
+ offset: [0, 0],
+ scale: 1
+ } as any // Mock the DragAndScale type
+})
+
+describe('useCanvasTransformSync', () => {
+ let mockCanvas: LGraphCanvas
+ let syncFn: ReturnType
+ let callbacks: {
+ onStart: ReturnType
+ onUpdate: ReturnType
+ onStop: ReturnType
+ }
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ mockCanvas = createMockCanvas() as LGraphCanvas
+ syncFn = vi.fn()
+ callbacks = {
+ onStart: vi.fn(),
+ onUpdate: vi.fn(),
+ onStop: vi.fn()
+ }
+
+ // Mock requestAnimationFrame
+ global.requestAnimationFrame = vi.fn((cb) => {
+ setTimeout(cb, 16) // Simulate 60fps
+ return 1
+ })
+ global.cancelAnimationFrame = vi.fn()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('should auto-start sync when canvas is provided', async () => {
+ const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
+
+ await nextTick()
+
+ expect(isActive.value).toBe(true)
+ expect(callbacks.onStart).toHaveBeenCalledOnce()
+ expect(syncFn).toHaveBeenCalledWith(mockCanvas)
+ })
+
+ it('should not auto-start when autoStart is false', async () => {
+ const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
+ autoStart: false
+ })
+
+ await nextTick()
+
+ expect(isActive.value).toBe(false)
+ expect(callbacks.onStart).not.toHaveBeenCalled()
+ expect(syncFn).not.toHaveBeenCalled()
+ })
+
+ it('should not start when canvas is null', async () => {
+ const { isActive } = useCanvasTransformSync(null, syncFn, callbacks)
+
+ await nextTick()
+
+ expect(isActive.value).toBe(false)
+ expect(callbacks.onStart).not.toHaveBeenCalled()
+ })
+
+ it('should manually start and stop sync', async () => {
+ const { isActive, startSync, stopSync } = useCanvasTransformSync(
+ mockCanvas,
+ syncFn,
+ callbacks,
+ { autoStart: false }
+ )
+
+ // Start manually
+ startSync()
+ await nextTick()
+
+ expect(isActive.value).toBe(true)
+ expect(callbacks.onStart).toHaveBeenCalledOnce()
+
+ // Stop manually
+ stopSync()
+ await nextTick()
+
+ expect(isActive.value).toBe(false)
+ expect(callbacks.onStop).toHaveBeenCalledOnce()
+ })
+
+ it('should call sync function on each frame', async () => {
+ useCanvasTransformSync(mockCanvas, syncFn, callbacks)
+
+ await nextTick()
+
+ // Advance timers to trigger additional frames (initial call + 3 more = 4 total)
+ vi.advanceTimersByTime(48) // 3 additional frames at 16ms each
+ await nextTick()
+
+ expect(syncFn).toHaveBeenCalledTimes(4) // Initial call + 3 timed calls
+ expect(syncFn).toHaveBeenCalledWith(mockCanvas)
+ })
+
+ it('should provide timing information in onUpdate callback', async () => {
+ // Mock performance.now to return predictable values
+ const mockNow = vi.spyOn(performance, 'now')
+ mockNow.mockReturnValueOnce(0).mockReturnValueOnce(5) // 5ms duration
+
+ useCanvasTransformSync(mockCanvas, syncFn, callbacks)
+
+ await nextTick()
+
+ expect(callbacks.onUpdate).toHaveBeenCalledWith(5)
+ })
+
+ it('should handle sync function that throws errors', async () => {
+ const errorSyncFn = vi.fn().mockImplementation(() => {
+ throw new Error('Sync failed')
+ })
+
+ // Creating the composable should not throw
+ expect(() => {
+ useCanvasTransformSync(mockCanvas, errorSyncFn, callbacks)
+ }).not.toThrow()
+
+ await nextTick()
+
+ // Even though sync function throws, the composable should handle it gracefully
+ expect(errorSyncFn).toHaveBeenCalled()
+ expect(callbacks.onStart).toHaveBeenCalled()
+ })
+
+ it('should not start if already active', async () => {
+ const { startSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
+
+ await nextTick()
+
+ // Try to start again
+ startSync()
+ await nextTick()
+
+ // Should only be called once from auto-start
+ expect(callbacks.onStart).toHaveBeenCalledOnce()
+ })
+
+ it('should not stop if already inactive', async () => {
+ const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
+ autoStart: false
+ })
+
+ // Try to stop when not started
+ stopSync()
+ await nextTick()
+
+ expect(callbacks.onStop).not.toHaveBeenCalled()
+ })
+
+ it('should clean up on component unmount', async () => {
+ const TestComponent = {
+ setup() {
+ const { isActive } = useCanvasTransformSync(
+ mockCanvas,
+ syncFn,
+ callbacks
+ )
+ return { isActive }
+ },
+ template: '{{ isActive }}
'
+ }
+
+ const wrapper = mount(TestComponent)
+ await nextTick()
+
+ expect(callbacks.onStart).toHaveBeenCalled()
+
+ // Unmount component
+ wrapper.unmount()
+ await nextTick()
+
+ expect(callbacks.onStop).toHaveBeenCalled()
+ expect(global.cancelAnimationFrame).toHaveBeenCalled()
+ })
+
+ it('should work without callbacks', async () => {
+ const { isActive } = useCanvasTransformSync(mockCanvas, syncFn)
+
+ await nextTick()
+
+ expect(isActive.value).toBe(true)
+ expect(syncFn).toHaveBeenCalledWith(mockCanvas)
+ })
+
+ it('should stop sync when canvas becomes null during sync', async () => {
+ let currentCanvas: any = mockCanvas
+ const dynamicSyncFn = vi.fn(() => {
+ // Simulate canvas becoming null during sync
+ currentCanvas = null
+ })
+
+ const { isActive } = useCanvasTransformSync(
+ currentCanvas,
+ dynamicSyncFn,
+ callbacks
+ )
+
+ await nextTick()
+
+ expect(isActive.value).toBe(true)
+
+ // Advance time to trigger sync
+ vi.advanceTimersByTime(16)
+ await nextTick()
+
+ // Should handle null canvas gracefully
+ expect(dynamicSyncFn).toHaveBeenCalled()
+ })
+
+ it('should use cancelAnimationFrame when stopping', async () => {
+ const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
+
+ await nextTick()
+
+ stopSync()
+
+ expect(global.cancelAnimationFrame).toHaveBeenCalledWith(1)
+ })
+})
diff --git a/tests-ui/tests/composables/graph/useSpatialIndex.test.ts b/tests-ui/tests/composables/graph/useSpatialIndex.test.ts
new file mode 100644
index 0000000000..4e34c0f04e
--- /dev/null
+++ b/tests-ui/tests/composables/graph/useSpatialIndex.test.ts
@@ -0,0 +1,498 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
+
+// Mock @vueuse/core
+vi.mock('@vueuse/core', () => ({
+ useDebounceFn: (fn: (...args: any[]) => any) => fn // Return function directly for testing
+}))
+
+describe('useSpatialIndex', () => {
+ let spatialIndex: ReturnType
+
+ beforeEach(() => {
+ spatialIndex = useSpatialIndex()
+ })
+
+ describe('initialization', () => {
+ it('should start with null quadTree', () => {
+ expect(spatialIndex.quadTree.value).toBeNull()
+ })
+
+ it('should initialize with default bounds when first node is added', () => {
+ const { updateNode, quadTree, metrics } = spatialIndex
+
+ updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
+
+ expect(quadTree.value).not.toBeNull()
+ expect(metrics.value.totalNodes).toBe(1)
+ })
+
+ it('should initialize with custom bounds', () => {
+ const { initialize, quadTree } = spatialIndex
+ const customBounds = { x: 0, y: 0, width: 5000, height: 3000 }
+
+ initialize(customBounds)
+
+ expect(quadTree.value).not.toBeNull()
+ })
+
+ it('should increment rebuild count on initialization', () => {
+ const { initialize, metrics } = spatialIndex
+
+ expect(metrics.value.rebuildCount).toBe(0)
+ initialize()
+ expect(metrics.value.rebuildCount).toBe(1)
+ })
+
+ it('should accept custom options', () => {
+ const customIndex = useSpatialIndex({
+ maxDepth: 8,
+ maxItemsPerNode: 6,
+ updateDebounceMs: 32
+ })
+
+ customIndex.initialize()
+
+ expect(customIndex.quadTree.value).not.toBeNull()
+ })
+ })
+
+ describe('updateNode', () => {
+ it('should add a new node to the index', () => {
+ const { updateNode, metrics } = spatialIndex
+
+ updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
+
+ expect(metrics.value.totalNodes).toBe(1)
+ })
+
+ it('should update existing node position', () => {
+ const { updateNode, queryViewport } = spatialIndex
+
+ // Add node
+ updateNode('node1', { x: 100, y: 100 }, { width: 200, height: 100 })
+
+ // Move node
+ updateNode('node1', { x: 500, y: 500 }, { width: 200, height: 100 })
+
+ // Query old position - should not find node
+ const oldResults = queryViewport({
+ x: 50,
+ y: 50,
+ width: 300,
+ height: 200
+ })
+ expect(oldResults).not.toContain('node1')
+
+ // Query new position - should find node
+ const newResults = queryViewport({
+ x: 450,
+ y: 450,
+ width: 300,
+ height: 200
+ })
+ expect(newResults).toContain('node1')
+ })
+
+ it('should auto-initialize if quadTree is null', () => {
+ const { updateNode, quadTree } = spatialIndex
+
+ expect(quadTree.value).toBeNull()
+ updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 })
+ expect(quadTree.value).not.toBeNull()
+ })
+ })
+
+ describe('batchUpdate', () => {
+ it('should update multiple nodes at once', () => {
+ const { batchUpdate, metrics } = spatialIndex
+
+ const updates = [
+ {
+ id: 'node1',
+ position: { x: 100, y: 100 },
+ size: { width: 200, height: 100 }
+ },
+ {
+ id: 'node2',
+ position: { x: 300, y: 300 },
+ size: { width: 150, height: 150 }
+ },
+ {
+ id: 'node3',
+ position: { x: 500, y: 200 },
+ size: { width: 100, height: 200 }
+ }
+ ]
+
+ batchUpdate(updates)
+
+ expect(metrics.value.totalNodes).toBe(3)
+ })
+
+ it('should handle empty batch', () => {
+ const { batchUpdate, metrics } = spatialIndex
+
+ batchUpdate([])
+
+ expect(metrics.value.totalNodes).toBe(0)
+ })
+
+ it('should auto-initialize if needed', () => {
+ const { batchUpdate, quadTree } = spatialIndex
+
+ expect(quadTree.value).toBeNull()
+ batchUpdate([
+ {
+ id: 'node1',
+ position: { x: 0, y: 0 },
+ size: { width: 100, height: 100 }
+ }
+ ])
+ expect(quadTree.value).not.toBeNull()
+ })
+ })
+
+ describe('removeNode', () => {
+ beforeEach(() => {
+ spatialIndex.updateNode(
+ 'node1',
+ { x: 100, y: 100 },
+ { width: 200, height: 100 }
+ )
+ spatialIndex.updateNode(
+ 'node2',
+ { x: 300, y: 300 },
+ { width: 200, height: 100 }
+ )
+ })
+
+ it('should remove node from index', () => {
+ const { removeNode, metrics } = spatialIndex
+
+ expect(metrics.value.totalNodes).toBe(2)
+ removeNode('node1')
+ expect(metrics.value.totalNodes).toBe(1)
+ })
+
+ it('should handle removing non-existent node', () => {
+ const { removeNode, metrics } = spatialIndex
+
+ expect(metrics.value.totalNodes).toBe(2)
+ removeNode('node999')
+ expect(metrics.value.totalNodes).toBe(2)
+ })
+
+ it('should handle removeNode when quadTree is null', () => {
+ const freshIndex = useSpatialIndex()
+
+ // Should not throw
+ expect(() => freshIndex.removeNode('node1')).not.toThrow()
+ })
+ })
+
+ describe('queryViewport', () => {
+ beforeEach(() => {
+ // Set up a grid of nodes
+ spatialIndex.updateNode(
+ 'node1',
+ { x: 0, y: 0 },
+ { width: 100, height: 100 }
+ )
+ spatialIndex.updateNode(
+ 'node2',
+ { x: 200, y: 0 },
+ { width: 100, height: 100 }
+ )
+ spatialIndex.updateNode(
+ 'node3',
+ { x: 0, y: 200 },
+ { width: 100, height: 100 }
+ )
+ spatialIndex.updateNode(
+ 'node4',
+ { x: 200, y: 200 },
+ { width: 100, height: 100 }
+ )
+ })
+
+ it('should find nodes within viewport bounds', () => {
+ const { queryViewport } = spatialIndex
+
+ // Query top-left quadrant
+ const results = queryViewport({ x: -50, y: -50, width: 200, height: 200 })
+ expect(results).toContain('node1')
+ expect(results).not.toContain('node2')
+ expect(results).not.toContain('node3')
+ expect(results).not.toContain('node4')
+ })
+
+ it('should find multiple nodes in larger viewport', () => {
+ const { queryViewport } = spatialIndex
+
+ // Query entire area
+ const results = queryViewport({ x: -50, y: -50, width: 400, height: 400 })
+ expect(results).toHaveLength(4)
+ expect(results).toContain('node1')
+ expect(results).toContain('node2')
+ expect(results).toContain('node3')
+ expect(results).toContain('node4')
+ })
+
+ it('should return empty array for empty region', () => {
+ const { queryViewport } = spatialIndex
+
+ const results = queryViewport({
+ x: 1000,
+ y: 1000,
+ width: 100,
+ height: 100
+ })
+ expect(results).toEqual([])
+ })
+
+ it('should update metrics after query', () => {
+ const { queryViewport, metrics } = spatialIndex
+
+ queryViewport({ x: 0, y: 0, width: 300, height: 300 })
+
+ expect(metrics.value.queryTime).toBeGreaterThan(0)
+ expect(metrics.value.visibleNodes).toBe(4)
+ })
+
+ it('should handle query when quadTree is null', () => {
+ const freshIndex = useSpatialIndex()
+
+ const results = freshIndex.queryViewport({
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 100
+ })
+ expect(results).toEqual([])
+ })
+ })
+
+ describe('queryRadius', () => {
+ beforeEach(() => {
+ // Set up nodes at different distances
+ spatialIndex.updateNode(
+ 'center',
+ { x: 475, y: 475 },
+ { width: 50, height: 50 }
+ )
+ spatialIndex.updateNode(
+ 'near1',
+ { x: 525, y: 475 },
+ { width: 50, height: 50 }
+ )
+ spatialIndex.updateNode(
+ 'near2',
+ { x: 425, y: 475 },
+ { width: 50, height: 50 }
+ )
+ spatialIndex.updateNode(
+ 'far',
+ { x: 775, y: 775 },
+ { width: 50, height: 50 }
+ )
+ })
+
+ it('should find nodes within radius', () => {
+ const { queryRadius } = spatialIndex
+
+ const results = queryRadius({ x: 500, y: 500 }, 100)
+
+ expect(results).toContain('center')
+ expect(results).toContain('near1')
+ expect(results).toContain('near2')
+ expect(results).not.toContain('far')
+ })
+
+ it('should handle zero radius', () => {
+ const { queryRadius } = spatialIndex
+
+ const results = queryRadius({ x: 500, y: 500 }, 0)
+
+ // Zero radius creates a point query at (500,500)
+ // The 'center' node spans 475-525 on both axes, so it contains this point
+ expect(results).toContain('center')
+ })
+
+ it('should handle large radius', () => {
+ const { queryRadius } = spatialIndex
+
+ const results = queryRadius({ x: 500, y: 500 }, 1000)
+
+ expect(results).toHaveLength(4) // Should find all nodes
+ })
+ })
+
+ describe('clear', () => {
+ beforeEach(() => {
+ spatialIndex.updateNode(
+ 'node1',
+ { x: 100, y: 100 },
+ { width: 200, height: 100 }
+ )
+ spatialIndex.updateNode(
+ 'node2',
+ { x: 300, y: 300 },
+ { width: 200, height: 100 }
+ )
+ })
+
+ it('should remove all nodes', () => {
+ const { clear, metrics } = spatialIndex
+
+ expect(metrics.value.totalNodes).toBe(2)
+ clear()
+ expect(metrics.value.totalNodes).toBe(0)
+ })
+
+ it('should reset metrics', () => {
+ const { clear, queryViewport, metrics } = spatialIndex
+
+ // Do a query to set visible nodes
+ queryViewport({ x: 0, y: 0, width: 500, height: 500 })
+ expect(metrics.value.visibleNodes).toBe(2)
+
+ clear()
+ expect(metrics.value.visibleNodes).toBe(0)
+ })
+
+ it('should handle clear when quadTree is null', () => {
+ const freshIndex = useSpatialIndex()
+
+ expect(() => freshIndex.clear()).not.toThrow()
+ })
+ })
+
+ describe('rebuild', () => {
+ it('should rebuild index with new nodes', () => {
+ const { rebuild, metrics, queryViewport } = spatialIndex
+
+ // Add initial nodes
+ spatialIndex.updateNode(
+ 'old1',
+ { x: 0, y: 0 },
+ { width: 100, height: 100 }
+ )
+ expect(metrics.value.rebuildCount).toBe(1)
+
+ // Rebuild with new set
+ const newNodes = new Map([
+ [
+ 'new1',
+ { position: { x: 100, y: 100 }, size: { width: 50, height: 50 } }
+ ],
+ [
+ 'new2',
+ { position: { x: 200, y: 200 }, size: { width: 50, height: 50 } }
+ ]
+ ])
+
+ rebuild(newNodes)
+
+ expect(metrics.value.totalNodes).toBe(2)
+ expect(metrics.value.rebuildCount).toBe(2)
+
+ // Old nodes should be gone
+ const oldResults = queryViewport({
+ x: -50,
+ y: -50,
+ width: 100,
+ height: 100
+ })
+ expect(oldResults).not.toContain('old1')
+
+ // New nodes should be findable
+ const newResults = queryViewport({
+ x: 50,
+ y: 50,
+ width: 200,
+ height: 200
+ })
+ expect(newResults).toContain('new1')
+ expect(newResults).toContain('new2')
+ })
+
+ it('should handle empty rebuild', () => {
+ const { rebuild, metrics } = spatialIndex
+
+ rebuild(new Map())
+
+ expect(metrics.value.totalNodes).toBe(0)
+ })
+ })
+
+ describe('metrics', () => {
+ it('should track performance metrics', () => {
+ const { metrics, updateNode, queryViewport } = spatialIndex
+
+ // Initial state
+ expect(metrics.value).toEqual({
+ queryTime: 0,
+ totalNodes: 0,
+ visibleNodes: 0,
+ treeDepth: 0,
+ rebuildCount: 0
+ })
+
+ // Add nodes
+ updateNode('node1', { x: 0, y: 0 }, { width: 100, height: 100 })
+ expect(metrics.value.totalNodes).toBe(1)
+
+ // Query
+ queryViewport({ x: -50, y: -50, width: 200, height: 200 })
+ expect(metrics.value.queryTime).toBeGreaterThan(0)
+ expect(metrics.value.visibleNodes).toBe(1)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle nodes with zero size', () => {
+ const { updateNode, queryViewport } = spatialIndex
+
+ updateNode('point', { x: 100, y: 100 }, { width: 0, height: 0 })
+
+ // Should still be findable
+ const results = queryViewport({ x: 50, y: 50, width: 100, height: 100 })
+ expect(results).toContain('point')
+ })
+
+ it('should handle negative positions', () => {
+ const { updateNode, queryViewport } = spatialIndex
+
+ updateNode('negative', { x: -500, y: -500 }, { width: 100, height: 100 })
+
+ const results = queryViewport({
+ x: -600,
+ y: -600,
+ width: 200,
+ height: 200
+ })
+ expect(results).toContain('negative')
+ })
+
+ it('should handle very large nodes', () => {
+ const { updateNode, queryViewport } = spatialIndex
+
+ updateNode('huge', { x: 0, y: 0 }, { width: 5000, height: 5000 })
+
+ // Should be found even when querying small area within it
+ const results = queryViewport({ x: 100, y: 100, width: 10, height: 10 })
+ expect(results).toContain('huge')
+ })
+ })
+
+ describe('debouncedUpdateNode', () => {
+ it('should be available', () => {
+ const { debouncedUpdateNode } = spatialIndex
+
+ expect(debouncedUpdateNode).toBeDefined()
+ expect(typeof debouncedUpdateNode).toBe('function')
+ })
+ })
+})
diff --git a/tests-ui/tests/composables/graph/useTransformSettling.test.ts b/tests-ui/tests/composables/graph/useTransformSettling.test.ts
new file mode 100644
index 0000000000..2bc6342c84
--- /dev/null
+++ b/tests-ui/tests/composables/graph/useTransformSettling.test.ts
@@ -0,0 +1,277 @@
+import { mount } from '@vue/test-utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick, ref } from 'vue'
+
+import { useTransformSettling } from '@/composables/graph/useTransformSettling'
+
+describe('useTransformSettling', () => {
+ let element: HTMLDivElement
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ element = document.createElement('div')
+ document.body.appendChild(element)
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ document.body.removeChild(element)
+ })
+
+ it('should track wheel events and settle after delay', async () => {
+ const { isTransforming } = useTransformSettling(element)
+
+ // Initially not transforming
+ expect(isTransforming.value).toBe(false)
+
+ // Dispatch wheel event
+ element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
+ await nextTick()
+
+ // Should be transforming
+ expect(isTransforming.value).toBe(true)
+
+ // Advance time but not past settle delay
+ vi.advanceTimersByTime(100)
+ expect(isTransforming.value).toBe(true)
+
+ // Advance past settle delay (default 200ms)
+ vi.advanceTimersByTime(150)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should reset settle timer on subsequent wheel events', async () => {
+ const { isTransforming } = useTransformSettling(element, {
+ settleDelay: 300
+ })
+
+ // First wheel event
+ element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
+ await nextTick()
+ expect(isTransforming.value).toBe(true)
+
+ // Advance time partially
+ vi.advanceTimersByTime(200)
+ expect(isTransforming.value).toBe(true)
+
+ // Another wheel event should reset the timer
+ element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
+ await nextTick()
+
+ // Advance 200ms more - should still be transforming
+ vi.advanceTimersByTime(200)
+ expect(isTransforming.value).toBe(true)
+
+ // Need another 100ms to settle (300ms total from last event)
+ vi.advanceTimersByTime(100)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should track pan events when trackPan is enabled', async () => {
+ const { isTransforming } = useTransformSettling(element, {
+ trackPan: true,
+ settleDelay: 200
+ })
+
+ // Pointer down should start transform
+ element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ await nextTick()
+ expect(isTransforming.value).toBe(true)
+
+ // Pointer move should keep it active
+ vi.advanceTimersByTime(100)
+ element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
+ await nextTick()
+
+ // Should still be transforming
+ expect(isTransforming.value).toBe(true)
+
+ // Pointer up
+ element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
+ await nextTick()
+
+ // Should still be transforming until settle delay
+ expect(isTransforming.value).toBe(true)
+
+ // Advance past settle delay
+ vi.advanceTimersByTime(200)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should not track pan events when trackPan is disabled', async () => {
+ const { isTransforming } = useTransformSettling(element, {
+ trackPan: false
+ })
+
+ // Pointer events should not trigger transform
+ element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
+ await nextTick()
+
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should handle pointer cancel events', async () => {
+ const { isTransforming } = useTransformSettling(element, {
+ trackPan: true,
+ settleDelay: 200
+ })
+
+ // Start panning
+ element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ await nextTick()
+ expect(isTransforming.value).toBe(true)
+
+ // Cancel instead of up
+ element.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
+ await nextTick()
+
+ // Should still settle normally
+ vi.advanceTimersByTime(200)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should work with ref target', async () => {
+ const targetRef = ref(null)
+ const { isTransforming } = useTransformSettling(targetRef)
+
+ // No target yet
+ expect(isTransforming.value).toBe(false)
+
+ // Set target
+ targetRef.value = element
+ await nextTick()
+
+ // Now events should work
+ element.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
+ await nextTick()
+ expect(isTransforming.value).toBe(true)
+
+ vi.advanceTimersByTime(200)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should use capture phase for events', async () => {
+ const captureHandler = vi.fn()
+ const bubbleHandler = vi.fn()
+
+ // Add handlers to verify capture phase
+ element.addEventListener('wheel', captureHandler, true)
+ element.addEventListener('wheel', bubbleHandler, false)
+
+ const { isTransforming } = useTransformSettling(element)
+
+ // Create child element
+ const child = document.createElement('div')
+ element.appendChild(child)
+
+ // Dispatch event on child
+ child.dispatchEvent(new WheelEvent('wheel', { bubbles: true }))
+ await nextTick()
+
+ // Capture handler should be called before bubble handler
+ expect(captureHandler).toHaveBeenCalled()
+ expect(isTransforming.value).toBe(true)
+
+ element.removeEventListener('wheel', captureHandler, true)
+ element.removeEventListener('wheel', bubbleHandler, false)
+ })
+
+ it('should throttle pointer move events', async () => {
+ const { isTransforming } = useTransformSettling(element, {
+ trackPan: true,
+ pointerMoveThrottle: 50,
+ settleDelay: 100
+ })
+
+ // Start panning
+ element.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
+ await nextTick()
+
+ // Fire many pointer move events rapidly
+ for (let i = 0; i < 10; i++) {
+ element.dispatchEvent(new PointerEvent('pointermove', { bubbles: true }))
+ vi.advanceTimersByTime(5) // 5ms between events
+ }
+ await nextTick()
+
+ // Should still be transforming
+ expect(isTransforming.value).toBe(true)
+
+ // End panning
+ element.dispatchEvent(new PointerEvent('pointerup', { bubbles: true }))
+
+ // Advance past settle delay
+ vi.advanceTimersByTime(100)
+ expect(isTransforming.value).toBe(false)
+ })
+
+ it('should clean up event listeners when component unmounts', async () => {
+ const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener')
+
+ // Create a test component
+ const TestComponent = {
+ setup() {
+ const { isTransforming } = useTransformSettling(element, {
+ trackPan: true
+ })
+ return { isTransforming }
+ },
+ template: '{{ isTransforming }}
'
+ }
+
+ const wrapper = mount(TestComponent)
+ await nextTick()
+
+ // Unmount component
+ wrapper.unmount()
+
+ // Should have removed all event listeners
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.objectContaining({ capture: true })
+ )
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'pointerdown',
+ expect.any(Function),
+ expect.objectContaining({ capture: true })
+ )
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'pointermove',
+ expect.any(Function),
+ expect.objectContaining({ capture: true })
+ )
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'pointerup',
+ expect.any(Function),
+ expect.objectContaining({ capture: true })
+ )
+ expect(removeEventListenerSpy).toHaveBeenCalledWith(
+ 'pointercancel',
+ expect.any(Function),
+ expect.objectContaining({ capture: true })
+ )
+ })
+
+ it('should use passive listeners when specified', async () => {
+ const addEventListenerSpy = vi.spyOn(element, 'addEventListener')
+
+ useTransformSettling(element, {
+ passive: true,
+ trackPan: true
+ })
+
+ // Check that passive option was used for appropriate events
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ 'wheel',
+ expect.any(Function),
+ expect.objectContaining({ passive: true, capture: true })
+ )
+ expect(addEventListenerSpy).toHaveBeenCalledWith(
+ 'pointermove',
+ expect.any(Function),
+ expect.objectContaining({ passive: true, capture: true })
+ )
+ })
+})
diff --git a/tests-ui/tests/composables/graph/useWidgetValue.test.ts b/tests-ui/tests/composables/graph/useWidgetValue.test.ts
new file mode 100644
index 0000000000..1557230740
--- /dev/null
+++ b/tests-ui/tests/composables/graph/useWidgetValue.test.ts
@@ -0,0 +1,503 @@
+import {
+ type MockedFunction,
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi
+} from 'vitest'
+import { ref } from 'vue'
+
+import {
+ useBooleanWidgetValue,
+ useNumberWidgetValue,
+ useStringWidgetValue,
+ useWidgetValue
+} from '@/composables/graph/useWidgetValue'
+import type { SimplifiedWidget } from '@/types/simplifiedWidget'
+
+describe('useWidgetValue', () => {
+ let mockWidget: SimplifiedWidget
+ let mockEmit: MockedFunction<(event: 'update:modelValue', value: any) => void>
+ let consoleWarnSpy: ReturnType
+
+ beforeEach(() => {
+ mockWidget = {
+ name: 'testWidget',
+ type: 'string',
+ value: 'initial',
+ callback: vi.fn()
+ }
+ mockEmit = vi.fn()
+ consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
+ })
+
+ afterEach(() => {
+ consoleWarnSpy.mockRestore()
+ })
+
+ describe('basic functionality', () => {
+ it('should initialize with modelValue', () => {
+ const { localValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'test value',
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('test value')
+ })
+
+ it('should use defaultValue when modelValue is null', () => {
+ const { localValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: null as any,
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('default')
+ })
+
+ it('should use defaultValue when modelValue is undefined', () => {
+ const { localValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: undefined as any,
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('default')
+ })
+ })
+
+ describe('onChange handler', () => {
+ it('should update localValue immediately', () => {
+ const { localValue, onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ onChange('new value')
+ expect(localValue.value).toBe('new value')
+ })
+
+ it('should emit update:modelValue event', () => {
+ const { onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ onChange('new value')
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
+ })
+
+ // useGraphNodeMaanger's createWrappedWidgetCallback makes the callback right now instead of useWidgetValue
+ // it('should call widget callback if it exists', () => {
+ // const { onChange } = useWidgetValue({
+ // widget: mockWidget,
+ // modelValue: 'initial',
+ // defaultValue: '',
+ // emit: mockEmit
+ // })
+
+ // onChange('new value')
+ // expect(mockWidget.callback).toHaveBeenCalledWith('new value')
+ // })
+
+ it('should not error if widget callback is undefined', () => {
+ const widgetWithoutCallback = { ...mockWidget, callback: undefined }
+ const { onChange } = useWidgetValue({
+ widget: widgetWithoutCallback,
+ modelValue: 'initial',
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ expect(() => onChange('new value')).not.toThrow()
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new value')
+ })
+
+ it('should handle null values', () => {
+ const { localValue, onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ onChange(null as any)
+ expect(localValue.value).toBe('default')
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
+ })
+
+ it('should handle undefined values', () => {
+ const { localValue, onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ onChange(undefined as any)
+ expect(localValue.value).toBe('default')
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'default')
+ })
+ })
+
+ describe('type safety', () => {
+ it('should handle type mismatches with warning', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'numberWidget',
+ type: 'number',
+ value: 42,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useWidgetValue({
+ widget: numberWidget,
+ modelValue: 10,
+ defaultValue: 0,
+ emit: mockEmit
+ })
+
+ // Pass string to number widget
+ onChange('not a number' as any)
+
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
+ 'useWidgetValue: Type mismatch for widget numberWidget. Expected number, got string'
+ )
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0) // Uses defaultValue
+ })
+
+ it('should accept values of matching type', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'numberWidget',
+ type: 'number',
+ value: 42,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useWidgetValue({
+ widget: numberWidget,
+ modelValue: 10,
+ defaultValue: 0,
+ emit: mockEmit
+ })
+
+ onChange(25)
+ expect(consoleWarnSpy).not.toHaveBeenCalled()
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 25)
+ })
+ })
+
+ describe('transform function', () => {
+ it('should apply transform function to new values', () => {
+ const transform = vi.fn((value: string) => value.toUpperCase())
+ const { onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: '',
+ emit: mockEmit,
+ transform
+ })
+
+ onChange('hello')
+ expect(transform).toHaveBeenCalledWith('hello')
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'HELLO')
+ })
+
+ it('should skip type checking when transform is provided', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'numberWidget',
+ type: 'number',
+ value: 42,
+ callback: vi.fn()
+ }
+
+ const transform = (value: string) => parseInt(value, 10) || 0
+ const { onChange } = useWidgetValue({
+ widget: numberWidget,
+ modelValue: 10,
+ defaultValue: 0,
+ emit: mockEmit,
+ transform
+ })
+
+ onChange('123')
+ expect(consoleWarnSpy).not.toHaveBeenCalled()
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 123)
+ })
+ })
+
+ describe('external updates', () => {
+ it('should update localValue when modelValue changes', async () => {
+ const modelValue = ref('initial')
+ const { localValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: modelValue.value,
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('initial')
+
+ // Simulate parent updating modelValue
+ modelValue.value = 'updated externally'
+
+ // Re-create the composable with new value (simulating prop change)
+ const { localValue: newLocalValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: modelValue.value,
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ expect(newLocalValue.value).toBe('updated externally')
+ })
+
+ it('should handle external null values', async () => {
+ const modelValue = ref('initial')
+ const { localValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: modelValue.value!,
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('initial')
+
+ // Simulate external update to null
+ modelValue.value = null
+ const { localValue: newLocalValue } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: modelValue.value as any,
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ expect(newLocalValue.value).toBe('default')
+ })
+ })
+
+ describe('useStringWidgetValue helper', () => {
+ it('should handle string values correctly', () => {
+ const stringWidget: SimplifiedWidget = {
+ name: 'textWidget',
+ type: 'string',
+ value: 'hello',
+ callback: vi.fn()
+ }
+
+ const { localValue, onChange } = useStringWidgetValue(
+ stringWidget,
+ 'initial',
+ mockEmit
+ )
+
+ expect(localValue.value).toBe('initial')
+
+ onChange('new string')
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 'new string')
+ })
+
+ it('should transform undefined to empty string', () => {
+ const stringWidget: SimplifiedWidget = {
+ name: 'textWidget',
+ type: 'string',
+ value: '',
+ callback: vi.fn()
+ }
+
+ const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
+
+ onChange(undefined as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '')
+ })
+
+ it('should convert non-string values to string', () => {
+ const stringWidget: SimplifiedWidget = {
+ name: 'textWidget',
+ type: 'string',
+ value: '',
+ callback: vi.fn()
+ }
+
+ const { onChange } = useStringWidgetValue(stringWidget, '', mockEmit)
+
+ onChange(123 as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', '123')
+ })
+ })
+
+ describe('useNumberWidgetValue helper', () => {
+ it('should handle number values correctly', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'sliderWidget',
+ type: 'number',
+ value: 50,
+ callback: vi.fn()
+ }
+
+ const { localValue, onChange } = useNumberWidgetValue(
+ numberWidget,
+ 25,
+ mockEmit
+ )
+
+ expect(localValue.value).toBe(25)
+
+ onChange(75)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 75)
+ })
+
+ it('should handle array values from PrimeVue Slider', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'sliderWidget',
+ type: 'number',
+ value: 50,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
+
+ // PrimeVue Slider can emit number[]
+ onChange([42, 100] as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
+ })
+
+ it('should handle empty array', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'sliderWidget',
+ type: 'number',
+ value: 50,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useNumberWidgetValue(numberWidget, 25, mockEmit)
+
+ onChange([] as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
+ })
+
+ it('should convert string numbers', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'numberWidget',
+ type: 'number',
+ value: 0,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
+
+ onChange('42' as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 42)
+ })
+
+ it('should handle invalid number conversions', () => {
+ const numberWidget: SimplifiedWidget = {
+ name: 'numberWidget',
+ type: 'number',
+ value: 0,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useNumberWidgetValue(numberWidget, 0, mockEmit)
+
+ onChange('not-a-number' as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', 0)
+ })
+ })
+
+ describe('useBooleanWidgetValue helper', () => {
+ it('should handle boolean values correctly', () => {
+ const boolWidget: SimplifiedWidget = {
+ name: 'toggleWidget',
+ type: 'boolean',
+ value: false,
+ callback: vi.fn()
+ }
+
+ const { localValue, onChange } = useBooleanWidgetValue(
+ boolWidget,
+ true,
+ mockEmit
+ )
+
+ expect(localValue.value).toBe(true)
+
+ onChange(false)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
+ })
+
+ it('should convert truthy values to true', () => {
+ const boolWidget: SimplifiedWidget = {
+ name: 'toggleWidget',
+ type: 'boolean',
+ value: false,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useBooleanWidgetValue(boolWidget, false, mockEmit)
+
+ onChange('truthy' as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', true)
+ })
+
+ it('should convert falsy values to false', () => {
+ const boolWidget: SimplifiedWidget = {
+ name: 'toggleWidget',
+ type: 'boolean',
+ value: false,
+ callback: vi.fn()
+ }
+
+ const { onChange } = useBooleanWidgetValue(boolWidget, true, mockEmit)
+
+ onChange(0 as any)
+ expect(mockEmit).toHaveBeenCalledWith('update:modelValue', false)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle rapid onChange calls', () => {
+ const { onChange } = useWidgetValue({
+ widget: mockWidget,
+ modelValue: 'initial',
+ defaultValue: '',
+ emit: mockEmit
+ })
+
+ onChange('value1')
+ onChange('value2')
+ onChange('value3')
+
+ expect(mockEmit).toHaveBeenCalledTimes(3)
+ expect(mockEmit).toHaveBeenNthCalledWith(1, 'update:modelValue', 'value1')
+ expect(mockEmit).toHaveBeenNthCalledWith(2, 'update:modelValue', 'value2')
+ expect(mockEmit).toHaveBeenNthCalledWith(3, 'update:modelValue', 'value3')
+ })
+
+ it('should handle widget with all properties undefined', () => {
+ const minimalWidget = {
+ name: 'minimal',
+ type: 'unknown'
+ } as SimplifiedWidget
+
+ const { localValue, onChange } = useWidgetValue({
+ widget: minimalWidget,
+ modelValue: 'test',
+ defaultValue: 'default',
+ emit: mockEmit
+ })
+
+ expect(localValue.value).toBe('test')
+ expect(() => onChange('new')).not.toThrow()
+ })
+ })
+})
diff --git a/tests-ui/tests/composables/widgets/useManagerQueue.test.ts b/tests-ui/tests/composables/useManagerQueue.test.ts
similarity index 100%
rename from tests-ui/tests/composables/widgets/useManagerQueue.test.ts
rename to tests-ui/tests/composables/useManagerQueue.test.ts
diff --git a/tests-ui/tests/composables/useNodeChatHistory.test.ts b/tests-ui/tests/composables/useNodeChatHistory.test.ts
index 6a6ecc6c5b..0b47fabb6a 100644
--- a/tests-ui/tests/composables/useNodeChatHistory.test.ts
+++ b/tests-ui/tests/composables/useNodeChatHistory.test.ts
@@ -3,23 +3,26 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
-vi.mock('@/composables/widgets/useChatHistoryWidget', () => ({
- useChatHistoryWidget: () => {
- return (node: any, inputSpec: any) => {
- const widget = {
- name: inputSpec.name,
- type: inputSpec.type
- }
+vi.mock(
+ '@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget',
+ () => ({
+ useChatHistoryWidget: () => {
+ return (node: any, inputSpec: any) => {
+ const widget = {
+ name: inputSpec.name,
+ type: inputSpec.type
+ }
- if (!node.widgets) {
- node.widgets = []
- }
- node.widgets.push(widget)
+ if (!node.widgets) {
+ node.widgets = []
+ }
+ node.widgets.push(widget)
- return widget
+ return widget
+ }
}
- }
-}))
+ })
+)
// Mock LGraphNode type
type MockNode = {
diff --git a/tests-ui/tests/litegraph/core/LGraphNode.test.ts b/tests-ui/tests/litegraph/core/LGraphNode.test.ts
index aaf3fe59e8..1d47df5d8f 100644
--- a/tests-ui/tests/litegraph/core/LGraphNode.test.ts
+++ b/tests-ui/tests/litegraph/core/LGraphNode.test.ts
@@ -622,38 +622,4 @@ describe('LGraphNode', () => {
delete (node.constructor as any).slot_start_y
})
})
-
- describe('getInputPos', () => {
- test('should call getInputSlotPos with the correct input slot from inputs array', () => {
- const input0: INodeInputSlot = {
- name: 'in0',
- type: 'string',
- link: null,
- boundingRect: new Float32Array([0, 0, 0, 0])
- }
- const input1: INodeInputSlot = {
- name: 'in1',
- type: 'number',
- link: null,
- boundingRect: new Float32Array([0, 0, 0, 0]),
- pos: [5, 45]
- }
- node.inputs = [input0, input1]
- const spy = vi.spyOn(node, 'getInputSlotPos')
- node.getInputPos(1)
- expect(spy).toHaveBeenCalledWith(input1)
- const expectedPos: Point = [100 + 5, 200 + 45]
- expect(node.getInputPos(1)).toEqual(expectedPos)
- spy.mockClear()
- node.getInputPos(0)
- expect(spy).toHaveBeenCalledWith(input0)
- const slotIndex = 0
- const nodeOffsetY = (node.constructor as any).slot_start_y || 0
- const expectedDefaultY =
- 200 + (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT + nodeOffsetY
- const expectedDefaultX = 100 + LiteGraph.NODE_SLOT_HEIGHT * 0.5
- expect(node.getInputPos(0)).toEqual([expectedDefaultX, expectedDefaultY])
- spy.mockRestore()
- })
- })
})
diff --git a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
index 2f63410fba..d3e6de7b2e 100644
--- a/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
+++ b/tests-ui/tests/litegraph/core/__snapshots__/LGraph.test.ts.snap
@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -83,6 +84,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
+ "onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -133,6 +135,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -154,6 +157,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
+ "onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
@@ -205,6 +209,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
+ "changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -226,6 +231,7 @@ LGraph {
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
+ "onMouseDown": [Function],
"order": 0,
"outputs": [],
"progress": undefined,
diff --git a/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap b/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap
index b69165d8da..de81f64e79 100644
--- a/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap
+++ b/tests-ui/tests/litegraph/core/__snapshots__/litegraph.test.ts.snap
@@ -11,6 +11,17 @@ LiteGraphGlobal {
"CARD_SHAPE": 4,
"CENTER": 5,
"CIRCLE_SHAPE": 3,
+ "COMFY_VUE_NODE_DIMENSIONS": {
+ "components": {
+ "HEADER_HEIGHT": 34,
+ "SLOT_HEIGHT": 24,
+ "STANDARD_WIDGET_HEIGHT": 30,
+ },
+ "spacing": {
+ "BETWEEN_SLOTS_AND_BODY": 8,
+ "BETWEEN_WIDGETS": 8,
+ },
+ },
"CONNECTING_LINK_COLOR": "#AFA",
"Classes": {
"InputIndicators": [Function],
@@ -199,5 +210,6 @@ LiteGraphGlobal {
"truncateWidgetValuesFirst": false,
"use_uuids": false,
"uuidv4": [Function],
+ "vueNodesMode": false,
}
`;
diff --git a/tests-ui/tests/performance/spatialIndexPerformance.test.ts b/tests-ui/tests/performance/spatialIndexPerformance.test.ts
new file mode 100644
index 0000000000..e1281f48cf
--- /dev/null
+++ b/tests-ui/tests/performance/spatialIndexPerformance.test.ts
@@ -0,0 +1,406 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { useSpatialIndex } from '@/composables/graph/useSpatialIndex'
+import type { Bounds } from '@/utils/spatial/QuadTree'
+
+// Skip this entire suite on CI to avoid flaky performance timing
+const isCI = Boolean(process.env.CI)
+const describeIfNotCI = isCI ? describe.skip : describe
+
+describeIfNotCI('Spatial Index Performance', () => {
+ let spatialIndex: ReturnType
+
+ beforeEach(() => {
+ spatialIndex = useSpatialIndex({
+ maxDepth: 6,
+ maxItemsPerNode: 4,
+ updateDebounceMs: 0 // Disable debouncing for tests
+ })
+ })
+
+ describe('large scale operations', () => {
+ it('should handle 1000 node insertions efficiently', () => {
+ const startTime = performance.now()
+
+ // Generate 1000 nodes in a realistic distribution
+ const nodes = Array.from({ length: 1000 }, (_, i) => ({
+ id: `node${i}`,
+ position: {
+ x: (Math.random() - 0.5) * 10000,
+ y: (Math.random() - 0.5) * 10000
+ },
+ size: {
+ width: 150 + Math.random() * 100,
+ height: 100 + Math.random() * 50
+ }
+ }))
+
+ spatialIndex.batchUpdate(nodes)
+
+ const insertTime = performance.now() - startTime
+
+ // Should insert 1000 nodes in under 100ms
+ expect(insertTime).toBeLessThan(100)
+ expect(spatialIndex.metrics.value.totalNodes).toBe(1000)
+ })
+
+ it('should maintain fast viewport queries under load', () => {
+ // First populate with many nodes
+ const nodes = Array.from({ length: 1000 }, (_, i) => ({
+ id: `node${i}`,
+ position: {
+ x: (Math.random() - 0.5) * 10000,
+ y: (Math.random() - 0.5) * 10000
+ },
+ size: { width: 200, height: 100 }
+ }))
+ spatialIndex.batchUpdate(nodes)
+
+ // Now benchmark viewport queries
+ const queryCount = 100
+ const viewportBounds: Bounds = {
+ x: -960,
+ y: -540,
+ width: 1920,
+ height: 1080
+ }
+
+ const startTime = performance.now()
+
+ for (let i = 0; i < queryCount; i++) {
+ // Vary viewport position to test different tree regions
+ const offsetX = (i % 10) * 500
+ const offsetY = Math.floor(i / 10) * 300
+ spatialIndex.queryViewport({
+ x: viewportBounds.x + offsetX,
+ y: viewportBounds.y + offsetY,
+ width: viewportBounds.width,
+ height: viewportBounds.height
+ })
+ }
+
+ const totalQueryTime = performance.now() - startTime
+ const avgQueryTime = totalQueryTime / queryCount
+
+ // Each query should take less than 2ms on average
+ expect(avgQueryTime).toBeLessThan(2)
+ expect(totalQueryTime).toBeLessThan(100) // 100 queries in under 100ms
+ })
+
+ it('should demonstrate performance advantage over linear search', () => {
+ // Create test data
+ const nodeCount = 500
+ const nodes = Array.from({ length: nodeCount }, (_, i) => ({
+ id: `node${i}`,
+ position: {
+ x: (Math.random() - 0.5) * 8000,
+ y: (Math.random() - 0.5) * 8000
+ },
+ size: { width: 200, height: 100 }
+ }))
+
+ // Populate spatial index
+ spatialIndex.batchUpdate(nodes)
+
+ const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
+ const queryCount = 50
+
+ // Benchmark spatial index queries
+ const spatialStartTime = performance.now()
+ for (let i = 0; i < queryCount; i++) {
+ spatialIndex.queryViewport(viewport)
+ }
+ const spatialTime = performance.now() - spatialStartTime
+
+ // Benchmark linear search equivalent
+ const linearStartTime = performance.now()
+ for (let i = 0; i < queryCount; i++) {
+ nodes.filter((node) => {
+ const nodeRight = node.position.x + node.size.width
+ const nodeBottom = node.position.y + node.size.height
+ const viewportRight = viewport.x + viewport.width
+ const viewportBottom = viewport.y + viewport.height
+
+ return !(
+ nodeRight < viewport.x ||
+ node.position.x > viewportRight ||
+ nodeBottom < viewport.y ||
+ node.position.y > viewportBottom
+ )
+ })
+ }
+ const linearTime = performance.now() - linearStartTime
+
+ // Spatial index should be faster than linear search
+ const speedup = linearTime / spatialTime
+ // In some environments, speedup may be less due to small dataset
+ // Just ensure spatial is not significantly slower (at least 10% of linear speed)
+ expect(speedup).toBeGreaterThan(0.1)
+
+ // Both should find roughly the same number of nodes
+ const spatialResults = spatialIndex.queryViewport(viewport)
+ const linearResults = nodes.filter((node) => {
+ const nodeRight = node.position.x + node.size.width
+ const nodeBottom = node.position.y + node.size.height
+ const viewportRight = viewport.x + viewport.width
+ const viewportBottom = viewport.y + viewport.height
+
+ return !(
+ nodeRight < viewport.x ||
+ node.position.x > viewportRight ||
+ nodeBottom < viewport.y ||
+ node.position.y > viewportBottom
+ )
+ })
+
+ // Results should be similar (within 10% due to QuadTree boundary effects)
+ const resultsDiff = Math.abs(spatialResults.length - linearResults.length)
+ const maxDiff =
+ Math.max(spatialResults.length, linearResults.length) * 0.1
+ expect(resultsDiff).toBeLessThan(maxDiff)
+ })
+ })
+
+ describe('update performance', () => {
+ it('should handle frequent position updates efficiently', () => {
+ // Add initial nodes
+ const nodeCount = 200
+ const initialNodes = Array.from({ length: nodeCount }, (_, i) => ({
+ id: `node${i}`,
+ position: { x: i * 100, y: i * 50 },
+ size: { width: 200, height: 100 }
+ }))
+ spatialIndex.batchUpdate(initialNodes)
+
+ // Benchmark frequent updates (simulating animation/dragging)
+ const updateCount = 100
+ const startTime = performance.now()
+
+ for (let frame = 0; frame < updateCount; frame++) {
+ // Update a subset of nodes each frame
+ for (let i = 0; i < 20; i++) {
+ const nodeId = `node${i}`
+ spatialIndex.updateNode(
+ nodeId,
+ {
+ x: i * 100 + Math.sin(frame * 0.1) * 50,
+ y: i * 50 + Math.cos(frame * 0.1) * 30
+ },
+ { width: 200, height: 100 }
+ )
+ }
+ }
+
+ const updateTime = performance.now() - startTime
+ const avgFrameTime = updateTime / updateCount
+
+ // Should maintain 60fps (16.67ms per frame) with 20 node updates per frame
+ expect(avgFrameTime).toBeLessThan(8) // Conservative target: 8ms per frame
+ })
+
+ it('should handle node additions and removals efficiently', () => {
+ const startTime = performance.now()
+
+ // Add nodes
+ for (let i = 0; i < 100; i++) {
+ spatialIndex.updateNode(
+ `node${i}`,
+ { x: Math.random() * 1000, y: Math.random() * 1000 },
+ { width: 200, height: 100 }
+ )
+ }
+
+ // Remove half of them
+ for (let i = 0; i < 50; i++) {
+ spatialIndex.removeNode(`node${i}`)
+ }
+
+ // Add new ones
+ for (let i = 100; i < 150; i++) {
+ spatialIndex.updateNode(
+ `node${i}`,
+ { x: Math.random() * 1000, y: Math.random() * 1000 },
+ { width: 200, height: 100 }
+ )
+ }
+
+ const totalTime = performance.now() - startTime
+
+ // All operations should complete quickly
+ expect(totalTime).toBeLessThan(50)
+ expect(spatialIndex.metrics.value.totalNodes).toBe(100) // 50 remaining + 50 new
+ })
+ })
+
+ describe('memory and scaling', () => {
+ it('should scale efficiently with increasing node counts', () => {
+ const nodeCounts = [100, 200, 500, 1000]
+ const queryTimes: number[] = []
+
+ for (const nodeCount of nodeCounts) {
+ // Create fresh spatial index for each test
+ const testIndex = useSpatialIndex({ updateDebounceMs: 0 })
+
+ // Populate with nodes
+ const nodes = Array.from({ length: nodeCount }, (_, i) => ({
+ id: `node${i}`,
+ position: {
+ x: (Math.random() - 0.5) * 10000,
+ y: (Math.random() - 0.5) * 10000
+ },
+ size: { width: 200, height: 100 }
+ }))
+ testIndex.batchUpdate(nodes)
+
+ // Benchmark query time
+ const viewport: Bounds = { x: -500, y: -300, width: 1000, height: 600 }
+ const startTime = performance.now()
+
+ for (let i = 0; i < 10; i++) {
+ testIndex.queryViewport(viewport)
+ }
+
+ const avgTime = (performance.now() - startTime) / 10
+ queryTimes.push(avgTime)
+ }
+
+ // Query time should scale logarithmically, not linearly
+ // The ratio between 1000 nodes and 100 nodes should be less than 5x
+ const ratio100to1000 = queryTimes[3] / queryTimes[0]
+ expect(ratio100to1000).toBeLessThan(5)
+
+ // All query times should be reasonable
+ queryTimes.forEach((time) => {
+ expect(time).toBeLessThan(5) // Each query under 5ms
+ })
+ })
+
+ it('should handle edge cases without performance degradation', () => {
+ // Test with very large nodes
+ spatialIndex.updateNode(
+ 'huge-node',
+ { x: -1000, y: -1000 },
+ { width: 5000, height: 3000 }
+ )
+
+ // Test with many tiny nodes
+ for (let i = 0; i < 100; i++) {
+ spatialIndex.updateNode(
+ `tiny-${i}`,
+ { x: Math.random() * 100, y: Math.random() * 100 },
+ { width: 1, height: 1 }
+ )
+ }
+
+ // Test with nodes at extreme coordinates
+ spatialIndex.updateNode(
+ 'extreme-pos',
+ { x: 50000, y: -50000 },
+ { width: 200, height: 100 }
+ )
+
+ spatialIndex.updateNode(
+ 'extreme-neg',
+ { x: -50000, y: 50000 },
+ { width: 200, height: 100 }
+ )
+
+ // Queries should still be fast
+ const startTime = performance.now()
+ for (let i = 0; i < 20; i++) {
+ spatialIndex.queryViewport({
+ x: Math.random() * 1000 - 500,
+ y: Math.random() * 1000 - 500,
+ width: 1000,
+ height: 600
+ })
+ }
+ const queryTime = performance.now() - startTime
+
+ expect(queryTime).toBeLessThan(20) // 20 queries in under 20ms
+ })
+ })
+
+ describe('realistic workflow scenarios', () => {
+ it('should handle typical ComfyUI workflow performance', () => {
+ // Simulate a large ComfyUI workflow with clustered nodes
+ const clusters = [
+ { center: { x: 0, y: 0 }, nodeCount: 50 },
+ { center: { x: 2000, y: 0 }, nodeCount: 30 },
+ { center: { x: 4000, y: 1000 }, nodeCount: 40 },
+ { center: { x: 0, y: 2000 }, nodeCount: 35 }
+ ]
+
+ let nodeId = 0
+ const allNodes: Array<{
+ id: string
+ position: { x: number; y: number }
+ size: { width: number; height: number }
+ }> = []
+
+ // Create clustered nodes (realistic for ComfyUI workflows)
+ clusters.forEach((cluster) => {
+ for (let i = 0; i < cluster.nodeCount; i++) {
+ allNodes.push({
+ id: `node${nodeId++}`,
+ position: {
+ x: cluster.center.x + (Math.random() - 0.5) * 800,
+ y: cluster.center.y + (Math.random() - 0.5) * 600
+ },
+ size: {
+ width: 150 + Math.random() * 100,
+ height: 100 + Math.random() * 50
+ }
+ })
+ }
+ })
+
+ // Add the nodes
+ const setupTime = performance.now()
+ spatialIndex.batchUpdate(allNodes)
+ const setupDuration = performance.now() - setupTime
+
+ // Simulate user panning around the workflow
+ const viewportSize = { width: 1920, height: 1080 }
+ const panPositions = [
+ { x: -960, y: -540 }, // Center on first cluster
+ { x: 1040, y: -540 }, // Pan to second cluster
+ { x: 3040, y: 460 }, // Pan to third cluster
+ { x: -960, y: 1460 }, // Pan to fourth cluster
+ { x: 1000, y: 500 } // Overview position
+ ]
+
+ const panStartTime = performance.now()
+ const queryResults: number[] = []
+
+ panPositions.forEach((pos) => {
+ // Simulate multiple viewport queries during smooth panning
+ for (let step = 0; step < 10; step++) {
+ const results = spatialIndex.queryViewport({
+ x: pos.x + step * 20,
+ y: pos.y + step * 10,
+ width: viewportSize.width,
+ height: viewportSize.height
+ })
+ queryResults.push(results.length)
+ }
+ })
+
+ const panDuration = performance.now() - panStartTime
+ const avgQueryTime = panDuration / (panPositions.length * 10)
+
+ // Performance expectations for realistic workflows
+ expect(setupDuration).toBeLessThan(30) // Setup 155 nodes in under 30ms
+ expect(avgQueryTime).toBeLessThan(1.5) // Average query under 1.5ms
+ expect(panDuration).toBeLessThan(50) // All panning queries under 50ms
+
+ // Should have reasonable culling efficiency
+ const totalNodes = allNodes.length
+ const avgVisibleNodes =
+ queryResults.reduce((a, b) => a + b, 0) / queryResults.length
+ const cullRatio = (totalNodes - avgVisibleNodes) / totalNodes
+
+ expect(cullRatio).toBeGreaterThan(0.3) // At least 30% culling efficiency
+ })
+ })
+})
diff --git a/tests-ui/tests/performance/transformPerformance.test.ts b/tests-ui/tests/performance/transformPerformance.test.ts
new file mode 100644
index 0000000000..9863bbfffc
--- /dev/null
+++ b/tests-ui/tests/performance/transformPerformance.test.ts
@@ -0,0 +1,483 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { useTransformState } from '@/composables/element/useTransformState'
+
+// Mock canvas context for testing
+const createMockCanvasContext = () => ({
+ ds: {
+ offset: [0, 0] as [number, number],
+ scale: 1
+ }
+})
+
+// Skip this entire suite on CI to avoid flaky performance timing
+const isCI = Boolean(process.env.CI)
+const describeIfNotCI = isCI ? describe.skip : describe
+
+describeIfNotCI('Transform Performance', () => {
+ let transformState: ReturnType
+ let mockCanvas: any
+
+ beforeEach(() => {
+ transformState = useTransformState()
+ mockCanvas = createMockCanvasContext()
+ })
+
+ describe('coordinate conversion performance', () => {
+ it('should handle large batches of coordinate conversions efficiently', () => {
+ // Set up a realistic transform state
+ mockCanvas.ds.offset = [500, 300]
+ mockCanvas.ds.scale = 1.5
+ transformState.syncWithCanvas(mockCanvas)
+
+ const conversionCount = 10000
+ const points = Array.from({ length: conversionCount }, () => ({
+ x: Math.random() * 5000,
+ y: Math.random() * 3000
+ }))
+
+ // Benchmark canvas to screen conversions
+ const canvasToScreenStart = performance.now()
+ const screenPoints = points.map((point) =>
+ transformState.canvasToScreen(point)
+ )
+ const canvasToScreenTime = performance.now() - canvasToScreenStart
+
+ // Benchmark screen to canvas conversions
+ const screenToCanvasStart = performance.now()
+ const backToCanvas = screenPoints.map((point) =>
+ transformState.screenToCanvas(point)
+ )
+ const screenToCanvasTime = performance.now() - screenToCanvasStart
+
+ // Performance expectations
+ expect(canvasToScreenTime).toBeLessThan(20) // 10k conversions in under 20ms
+ expect(screenToCanvasTime).toBeLessThan(20) // 10k conversions in under 20ms
+
+ // Verify accuracy of round-trip conversion
+ const maxError = points.reduce((max, original, i) => {
+ const converted = backToCanvas[i]
+ const errorX = Math.abs(original.x - converted.x)
+ const errorY = Math.abs(original.y - converted.y)
+ return Math.max(max, errorX, errorY)
+ }, 0)
+
+ expect(maxError).toBeLessThan(0.001) // Sub-pixel accuracy
+ })
+
+ it('should maintain performance across different zoom levels', () => {
+ const zoomLevels = [0.1, 0.5, 1.0, 2.0, 5.0, 10.0]
+ const conversionCount = 1000
+ const testPoints = Array.from({ length: conversionCount }, () => ({
+ x: Math.random() * 2000,
+ y: Math.random() * 1500
+ }))
+
+ const performanceResults: number[] = []
+
+ zoomLevels.forEach((scale) => {
+ mockCanvas.ds.scale = scale
+ transformState.syncWithCanvas(mockCanvas)
+
+ const startTime = performance.now()
+ testPoints.forEach((point) => {
+ const screen = transformState.canvasToScreen(point)
+ transformState.screenToCanvas(screen)
+ })
+ const duration = performance.now() - startTime
+
+ performanceResults.push(duration)
+ })
+
+ // Performance should be consistent across zoom levels
+ const maxTime = Math.max(...performanceResults)
+ const minTime = Math.min(...performanceResults)
+ const variance = (maxTime - minTime) / minTime
+
+ expect(maxTime).toBeLessThan(20) // All zoom levels under 20ms
+ expect(variance).toBeLessThan(3.0) // Less than 300% variance between zoom levels
+ })
+
+ it('should handle extreme coordinate values efficiently', () => {
+ // Test with very large coordinate values
+ const extremePoints = [
+ { x: -100000, y: -100000 },
+ { x: 100000, y: 100000 },
+ { x: 0, y: 0 },
+ { x: -50000, y: 50000 },
+ { x: 1e6, y: -1e6 }
+ ]
+
+ // Test at extreme zoom levels
+ const extremeScales = [0.001, 1000]
+
+ extremeScales.forEach((scale) => {
+ mockCanvas.ds.scale = scale
+ mockCanvas.ds.offset = [1000, 500]
+ transformState.syncWithCanvas(mockCanvas)
+
+ const startTime = performance.now()
+
+ // Convert each point 100 times
+ extremePoints.forEach((point) => {
+ for (let i = 0; i < 100; i++) {
+ const screen = transformState.canvasToScreen(point)
+ transformState.screenToCanvas(screen)
+ }
+ })
+
+ const duration = performance.now() - startTime
+
+ expect(duration).toBeLessThan(5) // Should handle extremes efficiently
+ expect(
+ Number.isFinite(transformState.canvasToScreen(extremePoints[0]).x)
+ ).toBe(true)
+ expect(
+ Number.isFinite(transformState.canvasToScreen(extremePoints[0]).y)
+ ).toBe(true)
+ })
+ })
+ })
+
+ describe('viewport culling performance', () => {
+ it('should efficiently determine node visibility for large numbers of nodes', () => {
+ // Set up realistic viewport
+ const viewport = { width: 1920, height: 1080 }
+
+ // Generate many node positions
+ const nodeCount = 1000
+ const nodes = Array.from({ length: nodeCount }, () => ({
+ pos: [Math.random() * 10000, Math.random() * 6000] as ArrayLike,
+ size: [
+ 150 + Math.random() * 100,
+ 100 + Math.random() * 50
+ ] as ArrayLike
+ }))
+
+ // Test at different zoom levels and positions
+ const testConfigs = [
+ { scale: 0.5, offset: [0, 0] },
+ { scale: 1.0, offset: [2000, 1000] },
+ { scale: 2.0, offset: [-1000, -500] }
+ ]
+
+ testConfigs.forEach((config) => {
+ mockCanvas.ds.scale = config.scale
+ mockCanvas.ds.offset = config.offset
+ transformState.syncWithCanvas(mockCanvas)
+
+ const startTime = performance.now()
+
+ // Test viewport culling for all nodes
+ const visibleNodes = nodes.filter((node) =>
+ transformState.isNodeInViewport(node.pos, node.size, viewport)
+ )
+
+ const cullTime = performance.now() - startTime
+
+ expect(cullTime).toBeLessThan(10) // 1000 nodes culled in under 10ms
+ expect(visibleNodes.length).toBeLessThan(nodeCount) // Some culling should occur
+ expect(visibleNodes.length).toBeGreaterThanOrEqual(0) // Sanity check
+ })
+ })
+
+ it('should optimize culling with adaptive margins', () => {
+ const viewport = { width: 1280, height: 720 }
+ const testNode = {
+ pos: [1300, 100] as ArrayLike, // Just outside viewport
+ size: [200, 100] as ArrayLike
+ }
+
+ // Test margin adaptation at different zoom levels
+ const zoomTests = [
+ { scale: 0.05, expectedVisible: true }, // Low zoom, larger margin
+ { scale: 1.0, expectedVisible: true }, // Normal zoom, standard margin
+ { scale: 4.0, expectedVisible: false } // High zoom, tighter margin
+ ]
+
+ const marginTests: boolean[] = []
+ const timings: number[] = []
+
+ zoomTests.forEach((test) => {
+ mockCanvas.ds.scale = test.scale
+ mockCanvas.ds.offset = [0, 0]
+ transformState.syncWithCanvas(mockCanvas)
+
+ const startTime = performance.now()
+ const isVisible = transformState.isNodeInViewport(
+ testNode.pos,
+ testNode.size,
+ viewport,
+ 0.2 // 20% margin
+ )
+ const duration = performance.now() - startTime
+
+ marginTests.push(isVisible)
+ timings.push(duration)
+ })
+
+ // All culling operations should be very fast
+ timings.forEach((time) => {
+ expect(time).toBeLessThan(0.1) // Individual culling under 0.1ms
+ })
+
+ // Verify adaptive behavior (margins should work as expected)
+ expect(marginTests[0]).toBe(zoomTests[0].expectedVisible)
+ expect(marginTests[2]).toBe(zoomTests[2].expectedVisible)
+ })
+
+ it('should handle size-based culling efficiently', () => {
+ // Test nodes of various sizes
+ const nodeSizes = [
+ [1, 1], // Tiny node
+ [5, 5], // Small node
+ [50, 50], // Medium node
+ [200, 100], // Large node
+ [500, 300] // Very large node
+ ]
+
+ const viewport = { width: 1920, height: 1080 }
+
+ // Position all nodes in viewport center
+ const centerPos = [960, 540] as ArrayLike
+
+ nodeSizes.forEach((size) => {
+ // Test at very low zoom where size culling should activate
+ mockCanvas.ds.scale = 0.01 // Very low zoom
+ transformState.syncWithCanvas(mockCanvas)
+
+ const startTime = performance.now()
+ const isVisible = transformState.isNodeInViewport(
+ centerPos,
+ size as ArrayLike,
+ viewport
+ )
+ const cullTime = performance.now() - startTime
+
+ expect(cullTime).toBeLessThan(0.1) // Size culling under 0.1ms
+
+ // At 0.01 zoom, nodes need to be 400+ pixels to show as 4+ screen pixels
+ const screenSize = Math.max(size[0], size[1]) * 0.01
+ if (screenSize < 4) {
+ expect(isVisible).toBe(false)
+ } else {
+ expect(isVisible).toBe(true)
+ }
+ })
+ })
+ })
+
+ describe('transform state synchronization', () => {
+ it('should efficiently sync with canvas state changes', () => {
+ const syncCount = 1000
+ const transformUpdates = Array.from({ length: syncCount }, (_, i) => ({
+ offset: [Math.sin(i * 0.1) * 1000, Math.cos(i * 0.1) * 500],
+ scale: 0.5 + Math.sin(i * 0.05) * 0.4 // Scale between 0.1 and 0.9
+ }))
+
+ const startTime = performance.now()
+
+ transformUpdates.forEach((update) => {
+ mockCanvas.ds.offset = update.offset
+ mockCanvas.ds.scale = update.scale
+ transformState.syncWithCanvas(mockCanvas)
+ })
+
+ const syncTime = performance.now() - startTime
+
+ expect(syncTime).toBeLessThan(15) // 1000 syncs in under 15ms
+
+ // Verify final state is correct
+ const lastUpdate = transformUpdates[transformUpdates.length - 1]
+ expect(transformState.camera.x).toBe(lastUpdate.offset[0])
+ expect(transformState.camera.y).toBe(lastUpdate.offset[1])
+ expect(transformState.camera.z).toBe(lastUpdate.scale)
+ })
+
+ it('should generate CSS transform strings efficiently', () => {
+ const transformCount = 10000
+
+ // Set up varying transform states
+ const transforms = Array.from({ length: transformCount }, (_, i) => {
+ mockCanvas.ds.offset = [i * 10, i * 5]
+ mockCanvas.ds.scale = 0.5 + (i % 100) / 100
+ transformState.syncWithCanvas(mockCanvas)
+ return transformState.transformStyle.value
+ })
+
+ const startTime = performance.now()
+
+ // Access transform styles (triggers computed property)
+ transforms.forEach((style) => {
+ expect(style.transform).toContain('scale(')
+ expect(style.transform).toContain('translate(')
+ expect(style.transformOrigin).toBe('0 0')
+ })
+
+ const accessTime = performance.now() - startTime
+
+ expect(accessTime).toBeLessThan(200) // 10k style accesses in under 200ms
+ })
+ })
+
+ describe('bounds calculation performance', () => {
+ it('should calculate node screen bounds efficiently', () => {
+ // Set up realistic transform
+ mockCanvas.ds.offset = [200, 100]
+ mockCanvas.ds.scale = 1.5
+ transformState.syncWithCanvas(mockCanvas)
+
+ const nodeCount = 1000
+ const nodes = Array.from({ length: nodeCount }, () => ({
+ pos: [Math.random() * 5000, Math.random() * 3000] as ArrayLike,
+ size: [
+ 100 + Math.random() * 200,
+ 80 + Math.random() * 120
+ ] as ArrayLike
+ }))
+
+ const startTime = performance.now()
+
+ const bounds = nodes.map((node) =>
+ transformState.getNodeScreenBounds(node.pos, node.size)
+ )
+
+ const calcTime = performance.now() - startTime
+
+ expect(calcTime).toBeLessThan(15) // 1000 bounds calculations in under 15ms
+ expect(bounds).toHaveLength(nodeCount)
+
+ // Verify bounds are reasonable
+ bounds.forEach((bound) => {
+ expect(bound.width).toBeGreaterThan(0)
+ expect(bound.height).toBeGreaterThan(0)
+ expect(Number.isFinite(bound.x)).toBe(true)
+ expect(Number.isFinite(bound.y)).toBe(true)
+ })
+ })
+
+ it('should calculate viewport bounds efficiently', () => {
+ const viewportSizes = [
+ { width: 800, height: 600 },
+ { width: 1920, height: 1080 },
+ { width: 3840, height: 2160 },
+ { width: 1280, height: 720 }
+ ]
+
+ const margins = [0, 0.1, 0.2, 0.5]
+
+ const combinations = viewportSizes.flatMap((viewport) =>
+ margins.map((margin) => ({ viewport, margin }))
+ )
+
+ const startTime = performance.now()
+
+ const allBounds = combinations.map(({ viewport, margin }) => {
+ mockCanvas.ds.offset = [Math.random() * 1000, Math.random() * 500]
+ mockCanvas.ds.scale = 0.5 + Math.random() * 2
+ transformState.syncWithCanvas(mockCanvas)
+
+ return transformState.getViewportBounds(viewport, margin)
+ })
+
+ const calcTime = performance.now() - startTime
+
+ expect(calcTime).toBeLessThan(5) // All viewport calculations in under 5ms
+ expect(allBounds).toHaveLength(combinations.length)
+
+ // Verify bounds are reasonable
+ allBounds.forEach((bounds) => {
+ expect(bounds.width).toBeGreaterThan(0)
+ expect(bounds.height).toBeGreaterThan(0)
+ expect(Number.isFinite(bounds.x)).toBe(true)
+ expect(Number.isFinite(bounds.y)).toBe(true)
+ })
+ })
+ })
+
+ describe('real-world performance scenarios', () => {
+ it('should handle smooth panning performance', () => {
+ // Simulate smooth 60fps panning for 2 seconds
+ const frameCount = 120 // 2 seconds at 60fps
+ const panDistance = 2000 // Pan 2000 pixels
+
+ const frames: number[] = []
+
+ for (let frame = 0; frame < frameCount; frame++) {
+ const progress = frame / (frameCount - 1)
+ const x = progress * panDistance
+ const y = Math.sin(progress * Math.PI * 2) * 200 // Slight vertical wave
+
+ mockCanvas.ds.offset = [x, y]
+
+ const frameStart = performance.now()
+
+ // Typical operations during panning
+ transformState.syncWithCanvas(mockCanvas)
+ const style = transformState.transformStyle.value // Access transform style
+ expect(style.transform).toContain('translate') // Verify style is valid
+
+ // Simulate some coordinate conversions (mouse tracking, etc.)
+ for (let i = 0; i < 5; i++) {
+ const screen = transformState.canvasToScreen({
+ x: x + i * 100,
+ y: y + i * 50
+ })
+ transformState.screenToCanvas(screen)
+ }
+
+ const frameTime = performance.now() - frameStart
+ frames.push(frameTime)
+
+ // Each frame should be well under 16.67ms for 60fps
+ expect(frameTime).toBeLessThan(1) // Conservative: under 1ms per frame
+ }
+
+ const totalTime = frames.reduce((sum, time) => sum + time, 0)
+ const avgFrameTime = totalTime / frameCount
+
+ expect(avgFrameTime).toBeLessThan(0.5) // Average frame time under 0.5ms
+ expect(totalTime).toBeLessThan(60) // Total panning overhead under 60ms
+ })
+
+ it('should handle zoom performance with viewport updates', () => {
+ // Simulate smooth zoom from 0.1x to 10x
+ const zoomSteps = 100
+ const viewport = { width: 1920, height: 1080 }
+
+ const zoomTimes: number[] = []
+
+ for (let step = 0; step < zoomSteps; step++) {
+ const zoomLevel = Math.pow(10, (step / (zoomSteps - 1)) * 2 - 1) // 0.1 to 10
+ mockCanvas.ds.scale = zoomLevel
+
+ const stepStart = performance.now()
+
+ // Operations during zoom
+ transformState.syncWithCanvas(mockCanvas)
+
+ // Viewport bounds calculation (for culling)
+ transformState.getViewportBounds(viewport, 0.2)
+
+ // Test a few nodes for visibility
+ for (let i = 0; i < 10; i++) {
+ transformState.isNodeInViewport(
+ [i * 200, i * 150],
+ [200, 100],
+ viewport
+ )
+ }
+
+ const stepTime = performance.now() - stepStart
+ zoomTimes.push(stepTime)
+ }
+
+ const maxZoomTime = Math.max(...zoomTimes)
+ const avgZoomTime =
+ zoomTimes.reduce((sum, time) => sum + time, 0) / zoomSteps
+
+ expect(maxZoomTime).toBeLessThan(2) // No zoom step over 2ms
+ expect(avgZoomTime).toBeLessThan(1) // Average zoom step under 1ms
+ })
+ })
+})
diff --git a/tests-ui/tests/renderer/core/layout/layoutStore.test.ts b/tests-ui/tests/renderer/core/layout/layoutStore.test.ts
new file mode 100644
index 0000000000..9c64f468d4
--- /dev/null
+++ b/tests-ui/tests/renderer/core/layout/layoutStore.test.ts
@@ -0,0 +1,260 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
+import { LayoutSource, type NodeLayout } from '@/renderer/core/layout/types'
+
+describe('layoutStore CRDT operations', () => {
+ beforeEach(() => {
+ // Clear the store before each test
+ layoutStore.initializeFromLiteGraph([])
+ })
+ // Helper to create test node data
+ const createTestNode = (id: string): NodeLayout => ({
+ id,
+ position: { x: 100, y: 100 },
+ size: { width: 200, height: 100 },
+ zIndex: 0,
+ visible: true,
+ bounds: { x: 100, y: 100, width: 200, height: 100 }
+ })
+
+ it('should create and retrieve nodes', () => {
+ const nodeId = 'test-node-1'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.setSource(LayoutSource.External)
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+
+ // Retrieve node
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value).toEqual(layout)
+ })
+
+ it('should move nodes', () => {
+ const nodeId = 'test-node-2'
+ const layout = createTestNode(nodeId)
+
+ // Create node first
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+
+ // Move node
+ const newPosition = { x: 200, y: 300 }
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ entity: 'node',
+ nodeId,
+ position: newPosition,
+ previousPosition: layout.position,
+ timestamp: Date.now(),
+ source: LayoutSource.Vue,
+ actor: 'test'
+ })
+
+ // Verify position updated
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value?.position).toEqual(newPosition)
+ })
+
+ it('should resize nodes', () => {
+ const nodeId = 'test-node-3'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+
+ // Resize node
+ const newSize = { width: 300, height: 150 }
+ layoutStore.applyOperation({
+ type: 'resizeNode',
+ entity: 'node',
+ nodeId,
+ size: newSize,
+ previousSize: layout.size,
+ timestamp: Date.now(),
+ source: LayoutSource.Canvas,
+ actor: 'test'
+ })
+
+ // Verify size updated
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value?.size).toEqual(newSize)
+ })
+
+ it('should delete nodes', () => {
+ const nodeId = 'test-node-4'
+ const layout = createTestNode(nodeId)
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+
+ // Delete node
+ layoutStore.applyOperation({
+ type: 'deleteNode',
+ entity: 'node',
+ nodeId,
+ previousLayout: layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+
+ // Verify node deleted
+ const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
+ expect(nodeRef.value).toBeNull()
+ })
+
+ it('should handle source and actor tracking', async () => {
+ const nodeId = 'test-node-5'
+ const layout = createTestNode(nodeId)
+
+ // Set source and actor
+ layoutStore.setSource(LayoutSource.Vue)
+ layoutStore.setActor('user-123')
+
+ // Track change notifications AFTER setting source/actor
+ const changes: any[] = []
+ const unsubscribe = layoutStore.onChange((change) => {
+ changes.push(change)
+ })
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: Date.now(),
+ source: layoutStore.getCurrentSource(),
+ actor: layoutStore.getCurrentActor()
+ })
+
+ // Wait for async notification
+ await new Promise((resolve) => setTimeout(resolve, 50))
+
+ expect(changes.length).toBeGreaterThanOrEqual(1)
+ const lastChange = changes[changes.length - 1]
+ expect(lastChange.source).toBe('vue')
+ expect(lastChange.operation.actor).toBe('user-123')
+
+ unsubscribe()
+ })
+
+ it('should query nodes by spatial bounds', () => {
+ const nodes = [
+ { id: 'node-a', position: { x: 0, y: 0 } },
+ { id: 'node-b', position: { x: 100, y: 100 } },
+ { id: 'node-c', position: { x: 250, y: 250 } }
+ ]
+
+ // Create nodes with proper bounds
+ nodes.forEach(({ id, position }) => {
+ const layout: NodeLayout = {
+ ...createTestNode(id),
+ position,
+ bounds: {
+ x: position.x,
+ y: position.y,
+ width: 200,
+ height: 100
+ }
+ }
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId: id,
+ layout,
+ timestamp: Date.now(),
+ source: LayoutSource.External,
+ actor: 'test'
+ })
+ })
+
+ // Query nodes in bounds
+ const nodesInBounds = layoutStore.queryNodesInBounds({
+ x: 50,
+ y: 50,
+ width: 200,
+ height: 200
+ })
+
+ // node-a: (0,0) to (200,100) - overlaps with query bounds (50,50) to (250,250)
+ // node-b: (100,100) to (300,200) - overlaps with query bounds
+ // node-c: (250,250) to (450,350) - touches corner of query bounds
+ expect(nodesInBounds).toContain('node-a')
+ expect(nodesInBounds).toContain('node-b')
+ expect(nodesInBounds).toContain('node-c')
+ })
+
+ it('should maintain operation history', () => {
+ const nodeId = 'test-node-history'
+ const layout = createTestNode(nodeId)
+ const startTime = Date.now()
+
+ // Create node
+ layoutStore.applyOperation({
+ type: 'createNode',
+ entity: 'node',
+ nodeId,
+ layout,
+ timestamp: startTime,
+ source: LayoutSource.External,
+ actor: 'test-actor'
+ })
+
+ // Move node
+ layoutStore.applyOperation({
+ type: 'moveNode',
+ entity: 'node',
+ nodeId,
+ position: { x: 150, y: 150 },
+ previousPosition: { x: 100, y: 100 },
+ timestamp: startTime + 100,
+ source: LayoutSource.Vue,
+ actor: 'test-actor'
+ })
+
+ // Get operations by actor
+ const operations = layoutStore.getOperationsByActor('test-actor')
+ expect(operations.length).toBeGreaterThanOrEqual(2)
+ expect(operations[0].type).toBe('createNode')
+ expect(operations[1].type).toBe('moveNode')
+
+ // Get operations since timestamp
+ const recentOps = layoutStore.getOperationsSince(startTime + 50)
+ expect(recentOps.length).toBeGreaterThanOrEqual(1)
+ expect(recentOps[0].type).toBe('moveNode')
+ })
+})
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/lod/useLOD.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/lod/useLOD.test.ts
new file mode 100644
index 0000000000..68850c18aa
--- /dev/null
+++ b/tests-ui/tests/renderer/extensions/vueNodes/lod/useLOD.test.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+
+import {
+ LODLevel,
+ LOD_THRESHOLDS,
+ supportsFeatureAtZoom,
+ useLOD
+} from '@/renderer/extensions/vueNodes/lod/useLOD'
+
+describe('useLOD', () => {
+ describe('LOD level detection', () => {
+ it('should return MINIMAL for zoom <= 0.4', () => {
+ const zoomRef = ref(0.4)
+ const { lodLevel } = useLOD(zoomRef)
+ expect(lodLevel.value).toBe(LODLevel.MINIMAL)
+
+ zoomRef.value = 0.2
+ expect(lodLevel.value).toBe(LODLevel.MINIMAL)
+
+ zoomRef.value = 0.1
+ expect(lodLevel.value).toBe(LODLevel.MINIMAL)
+ })
+
+ it('should return REDUCED for 0.4 < zoom <= 0.8', () => {
+ const zoomRef = ref(0.5)
+ const { lodLevel } = useLOD(zoomRef)
+ expect(lodLevel.value).toBe(LODLevel.REDUCED)
+
+ zoomRef.value = 0.6
+ expect(lodLevel.value).toBe(LODLevel.REDUCED)
+
+ zoomRef.value = 0.8
+ expect(lodLevel.value).toBe(LODLevel.REDUCED)
+ })
+
+ it('should return FULL for zoom > 0.8', () => {
+ const zoomRef = ref(0.9)
+ const { lodLevel } = useLOD(zoomRef)
+ expect(lodLevel.value).toBe(LODLevel.FULL)
+
+ zoomRef.value = 1.0
+ expect(lodLevel.value).toBe(LODLevel.FULL)
+
+ zoomRef.value = 2.5
+ expect(lodLevel.value).toBe(LODLevel.FULL)
+ })
+
+ it('should be reactive to zoom changes', () => {
+ const zoomRef = ref(0.2)
+ const { lodLevel } = useLOD(zoomRef)
+
+ expect(lodLevel.value).toBe(LODLevel.MINIMAL)
+
+ zoomRef.value = 0.6
+ expect(lodLevel.value).toBe(LODLevel.REDUCED)
+
+ zoomRef.value = 1.0
+ expect(lodLevel.value).toBe(LODLevel.FULL)
+ })
+ })
+
+ describe('rendering decisions', () => {
+ it('should disable all rendering for MINIMAL LOD', () => {
+ const zoomRef = ref(0.2)
+ const {
+ shouldRenderWidgets,
+ shouldRenderSlots,
+ shouldRenderContent,
+ shouldRenderSlotLabels,
+ shouldRenderWidgetLabels
+ } = useLOD(zoomRef)
+
+ expect(shouldRenderWidgets.value).toBe(false)
+ expect(shouldRenderSlots.value).toBe(false)
+ expect(shouldRenderContent.value).toBe(false)
+ expect(shouldRenderSlotLabels.value).toBe(false)
+ expect(shouldRenderWidgetLabels.value).toBe(false)
+ })
+
+ it('should enable widgets/slots but disable labels for REDUCED LOD', () => {
+ const zoomRef = ref(0.6)
+ const {
+ shouldRenderWidgets,
+ shouldRenderSlots,
+ shouldRenderContent,
+ shouldRenderSlotLabels,
+ shouldRenderWidgetLabels
+ } = useLOD(zoomRef)
+
+ expect(shouldRenderWidgets.value).toBe(true)
+ expect(shouldRenderSlots.value).toBe(true)
+ expect(shouldRenderContent.value).toBe(false)
+ expect(shouldRenderSlotLabels.value).toBe(false)
+ expect(shouldRenderWidgetLabels.value).toBe(false)
+ })
+
+ it('should enable all rendering for FULL LOD', () => {
+ const zoomRef = ref(1.0)
+ const {
+ shouldRenderWidgets,
+ shouldRenderSlots,
+ shouldRenderContent,
+ shouldRenderSlotLabels,
+ shouldRenderWidgetLabels
+ } = useLOD(zoomRef)
+
+ expect(shouldRenderWidgets.value).toBe(true)
+ expect(shouldRenderSlots.value).toBe(true)
+ expect(shouldRenderContent.value).toBe(true)
+ expect(shouldRenderSlotLabels.value).toBe(true)
+ expect(shouldRenderWidgetLabels.value).toBe(true)
+ })
+ })
+
+ describe('CSS classes', () => {
+ it('should return correct CSS class for each LOD level', () => {
+ const zoomRef = ref(0.2)
+ const { lodCssClass } = useLOD(zoomRef)
+
+ expect(lodCssClass.value).toBe('lg-node--lod-minimal')
+
+ zoomRef.value = 0.6
+ expect(lodCssClass.value).toBe('lg-node--lod-reduced')
+
+ zoomRef.value = 1.0
+ expect(lodCssClass.value).toBe('lg-node--lod-full')
+ })
+ })
+
+ describe('essential widgets filtering', () => {
+ it('should return all widgets for FULL LOD', () => {
+ const zoomRef = ref(1.0)
+ const { getEssentialWidgets } = useLOD(zoomRef)
+
+ const widgets = [
+ { type: 'combo' },
+ { type: 'text' },
+ { type: 'button' },
+ { type: 'slider' }
+ ]
+
+ expect(getEssentialWidgets(widgets)).toEqual(widgets)
+ })
+
+ it('should return empty array for MINIMAL LOD', () => {
+ const zoomRef = ref(0.2)
+ const { getEssentialWidgets } = useLOD(zoomRef)
+
+ const widgets = [{ type: 'combo' }, { type: 'text' }, { type: 'button' }]
+
+ expect(getEssentialWidgets(widgets)).toEqual([])
+ })
+
+ it('should filter to essential types for REDUCED LOD', () => {
+ const zoomRef = ref(0.6)
+ const { getEssentialWidgets } = useLOD(zoomRef)
+
+ const widgets = [
+ { type: 'combo' },
+ { type: 'text' },
+ { type: 'button' },
+ { type: 'slider' },
+ { type: 'toggle' },
+ { type: 'number' }
+ ]
+
+ const essential = getEssentialWidgets(widgets)
+ expect(essential).toHaveLength(4)
+ expect(essential.map((w: any) => w.type)).toEqual([
+ 'combo',
+ 'slider',
+ 'toggle',
+ 'number'
+ ])
+ })
+
+ it('should handle case-insensitive widget types', () => {
+ const zoomRef = ref(0.6)
+ const { getEssentialWidgets } = useLOD(zoomRef)
+
+ const widgets = [
+ { type: 'COMBO' },
+ { type: 'Select' },
+ { type: 'TOGGLE' }
+ ]
+
+ const essential = getEssentialWidgets(widgets)
+ expect(essential).toHaveLength(3)
+ })
+
+ it('should handle widgets with undefined or missing type', () => {
+ const zoomRef = ref(0.6)
+ const { getEssentialWidgets } = useLOD(zoomRef)
+
+ const widgets = [
+ { type: 'combo' },
+ { type: undefined },
+ {},
+ { type: 'slider' }
+ ]
+
+ const essential = getEssentialWidgets(widgets)
+ expect(essential).toHaveLength(2)
+ expect(essential.map((w: any) => w.type)).toEqual(['combo', 'slider'])
+ })
+ })
+
+ describe('performance metrics', () => {
+ it('should provide debug metrics', () => {
+ const zoomRef = ref(0.6)
+ const { lodMetrics } = useLOD(zoomRef)
+
+ expect(lodMetrics.value).toEqual({
+ level: LODLevel.REDUCED,
+ zoom: 0.6,
+ widgetCount: 'full',
+ slotCount: 'full'
+ })
+ })
+
+ it('should update metrics when zoom changes', () => {
+ const zoomRef = ref(0.2)
+ const { lodMetrics } = useLOD(zoomRef)
+
+ expect(lodMetrics.value.level).toBe(LODLevel.MINIMAL)
+ expect(lodMetrics.value.widgetCount).toBe('none')
+ expect(lodMetrics.value.slotCount).toBe('none')
+
+ zoomRef.value = 1.0
+ expect(lodMetrics.value.level).toBe(LODLevel.FULL)
+ expect(lodMetrics.value.widgetCount).toBe('full')
+ expect(lodMetrics.value.slotCount).toBe('full')
+ })
+ })
+})
+
+describe('LOD_THRESHOLDS', () => {
+ it('should export correct threshold values', () => {
+ expect(LOD_THRESHOLDS.FULL_THRESHOLD).toBe(0.8)
+ expect(LOD_THRESHOLDS.REDUCED_THRESHOLD).toBe(0.4)
+ expect(LOD_THRESHOLDS.MINIMAL_THRESHOLD).toBe(0.0)
+ })
+})
+
+describe('supportsFeatureAtZoom', () => {
+ it('should return correct feature support for different zoom levels', () => {
+ expect(supportsFeatureAtZoom(1.0, 'renderWidgets')).toBe(true)
+ expect(supportsFeatureAtZoom(1.0, 'renderSlots')).toBe(true)
+ expect(supportsFeatureAtZoom(1.0, 'renderContent')).toBe(true)
+
+ expect(supportsFeatureAtZoom(0.6, 'renderWidgets')).toBe(true)
+ expect(supportsFeatureAtZoom(0.6, 'renderSlots')).toBe(true)
+ expect(supportsFeatureAtZoom(0.6, 'renderContent')).toBe(false)
+
+ expect(supportsFeatureAtZoom(0.2, 'renderWidgets')).toBe(false)
+ expect(supportsFeatureAtZoom(0.2, 'renderSlots')).toBe(false)
+ expect(supportsFeatureAtZoom(0.2, 'renderContent')).toBe(false)
+ })
+
+ it('should handle threshold boundary values correctly', () => {
+ expect(supportsFeatureAtZoom(0.8, 'renderWidgets')).toBe(true)
+ expect(supportsFeatureAtZoom(0.8, 'renderContent')).toBe(false)
+
+ expect(supportsFeatureAtZoom(0.81, 'renderContent')).toBe(true)
+
+ expect(supportsFeatureAtZoom(0.4, 'renderWidgets')).toBe(false)
+ expect(supportsFeatureAtZoom(0.41, 'renderWidgets')).toBe(true)
+ })
+})
diff --git a/tests-ui/tests/composables/widgets/useComboWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
similarity index 90%
rename from tests-ui/tests/composables/widgets/useComboWidget.test.ts
rename to tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
index cbdd74bab8..15936d1a48 100644
--- a/tests-ui/tests/composables/widgets/useComboWidget.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useComboWidget.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { useComboWidget } from '@/composables/widgets/useComboWidget'
+import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
vi.mock('@/scripts/widgets', () => ({
diff --git a/tests-ui/tests/composables/widgets/useFloatWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts
similarity index 95%
rename from tests-ui/tests/composables/widgets/useFloatWidget.test.ts
rename to tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts
index 02819c9f2d..6dde265a60 100644
--- a/tests-ui/tests/composables/widgets/useFloatWidget.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useFloatWidget.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { _for_testing } from '@/composables/widgets/useFloatWidget'
+import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useFloatWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
diff --git a/tests-ui/tests/composables/widgets/useIntWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
similarity index 94%
rename from tests-ui/tests/composables/widgets/useIntWidget.test.ts
rename to tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
index 8bed76042f..edf4efab97 100644
--- a/tests-ui/tests/composables/widgets/useIntWidget.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useIntWidget.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { _for_testing } from '@/composables/widgets/useIntWidget'
+import { _for_testing } from '@/renderer/extensions/vueNodes/widgets/composables/useIntWidget'
vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn()
diff --git a/tests-ui/tests/composables/widgets/useRemoteWidget.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
similarity index 99%
rename from tests-ui/tests/composables/widgets/useRemoteWidget.test.ts
rename to tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
index 0bf7294537..9b301cce01 100644
--- a/tests-ui/tests/composables/widgets/useRemoteWidget.test.ts
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget.test.ts
@@ -1,7 +1,7 @@
import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
+import { useRemoteWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useRemoteWidget'
import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
vi.mock('axios', () => {
diff --git a/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts
new file mode 100644
index 0000000000..4a363ebd1d
--- /dev/null
+++ b/tests-ui/tests/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.test.ts
@@ -0,0 +1,168 @@
+import { describe, expect, it } from 'vitest'
+
+import WidgetButton from '@/renderer/extensions/vueNodes/widgets/components/WidgetButton.vue'
+import WidgetColorPicker from '@/renderer/extensions/vueNodes/widgets/components/WidgetColorPicker.vue'
+import WidgetFileUpload from '@/renderer/extensions/vueNodes/widgets/components/WidgetFileUpload.vue'
+import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
+import WidgetMarkdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue'
+import WidgetSelect from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue'
+import WidgetSlider from '@/renderer/extensions/vueNodes/widgets/components/WidgetSlider.vue'
+import WidgetTextarea from '@/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue'
+import WidgetToggleSwitch from '@/renderer/extensions/vueNodes/widgets/components/WidgetToggleSwitch.vue'
+import {
+ getComponent,
+ isEssential,
+ shouldRenderAsVue
+} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
+
+describe('widgetRegistry', () => {
+ describe('getComponent', () => {
+ // Test number type mappings
+ describe('number types', () => {
+ it('should map int types to slider widget', () => {
+ expect(getComponent('int')).toBe(WidgetSlider)
+ expect(getComponent('INT')).toBe(WidgetSlider)
+ })
+
+ it('should map float types to slider widget', () => {
+ expect(getComponent('float')).toBe(WidgetSlider)
+ expect(getComponent('FLOAT')).toBe(WidgetSlider)
+ expect(getComponent('number')).toBe(WidgetSlider)
+ expect(getComponent('slider')).toBe(WidgetSlider)
+ })
+ })
+
+ // Test text type mappings
+ describe('text types', () => {
+ it('should map text variations to input text widget', () => {
+ expect(getComponent('text')).toBe(WidgetInputText)
+ expect(getComponent('string')).toBe(WidgetInputText)
+ expect(getComponent('STRING')).toBe(WidgetInputText)
+ })
+
+ it('should map multiline text types to textarea widget', () => {
+ expect(getComponent('multiline')).toBe(WidgetTextarea)
+ expect(getComponent('textarea')).toBe(WidgetTextarea)
+ expect(getComponent('TEXTAREA')).toBe(WidgetTextarea)
+ expect(getComponent('customtext')).toBe(WidgetTextarea)
+ })
+
+ it('should map markdown to markdown widget', () => {
+ expect(getComponent('MARKDOWN')).toBe(WidgetMarkdown)
+ expect(getComponent('markdown')).toBe(WidgetMarkdown)
+ })
+ })
+
+ // Test selection type mappings
+ describe('selection types', () => {
+ it('should map combo types to select widget', () => {
+ expect(getComponent('combo')).toBe(WidgetSelect)
+ expect(getComponent('COMBO')).toBe(WidgetSelect)
+ })
+ })
+
+ // Test boolean type mappings
+ describe('boolean types', () => {
+ it('should map boolean types to toggle switch widget', () => {
+ expect(getComponent('toggle')).toBe(WidgetToggleSwitch)
+ expect(getComponent('boolean')).toBe(WidgetToggleSwitch)
+ expect(getComponent('BOOLEAN')).toBe(WidgetToggleSwitch)
+ })
+ })
+
+ // Test advanced widget mappings
+ describe('advanced widgets', () => {
+ it('should map color types to color picker widget', () => {
+ expect(getComponent('color')).toBe(WidgetColorPicker)
+ expect(getComponent('COLOR')).toBe(WidgetColorPicker)
+ })
+
+ it('should map file types to file upload widget', () => {
+ expect(getComponent('file')).toBe(WidgetFileUpload)
+ expect(getComponent('fileupload')).toBe(WidgetFileUpload)
+ expect(getComponent('FILEUPLOAD')).toBe(WidgetFileUpload)
+ })
+
+ it('should map button types to button widget', () => {
+ expect(getComponent('button')).toBe(WidgetButton)
+ expect(getComponent('BUTTON')).toBe(WidgetButton)
+ })
+ })
+
+ // Test fallback behavior
+ describe('fallback behavior', () => {
+ it('should return null for unknown types', () => {
+ expect(getComponent('unknown')).toBe(null)
+ expect(getComponent('custom_widget')).toBe(null)
+ expect(getComponent('')).toBe(null)
+ })
+ })
+ })
+
+ describe('shouldRenderAsVue', () => {
+ it('should return false for widgets marked as canvas-only', () => {
+ const widget = { type: 'text', options: { canvasOnly: true } }
+ expect(shouldRenderAsVue(widget)).toBe(false)
+ })
+
+ it('should return false for widgets without a type', () => {
+ const widget = { options: {} }
+ expect(shouldRenderAsVue(widget)).toBe(false)
+ })
+
+ it('should return true for widgets with mapped types', () => {
+ expect(shouldRenderAsVue({ type: 'text' })).toBe(true)
+ expect(shouldRenderAsVue({ type: 'int' })).toBe(true)
+ expect(shouldRenderAsVue({ type: 'combo' })).toBe(true)
+ })
+
+ it('should return false for unknown types', () => {
+ expect(shouldRenderAsVue({ type: 'unknown_type' })).toBe(false)
+ })
+
+ it('should respect options while checking type', () => {
+ const widget = { type: 'text', options: { someOption: 'value' } }
+ expect(shouldRenderAsVue(widget)).toBe(true)
+ })
+ })
+
+ describe('isEssential', () => {
+ it('should identify essential widget types', () => {
+ expect(isEssential('int')).toBe(true)
+ expect(isEssential('INT')).toBe(true)
+ expect(isEssential('float')).toBe(true)
+ expect(isEssential('FLOAT')).toBe(true)
+ expect(isEssential('boolean')).toBe(true)
+ expect(isEssential('BOOLEAN')).toBe(true)
+ expect(isEssential('combo')).toBe(true)
+ expect(isEssential('COMBO')).toBe(true)
+ })
+
+ it('should identify non-essential widget types', () => {
+ expect(isEssential('button')).toBe(false)
+ expect(isEssential('color')).toBe(false)
+ expect(isEssential('chart')).toBe(false)
+ expect(isEssential('fileupload')).toBe(false)
+ })
+
+ it('should return false for unknown types', () => {
+ expect(isEssential('unknown')).toBe(false)
+ expect(isEssential('')).toBe(false)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle widgets with empty options', () => {
+ const widget = { type: 'text', options: {} }
+ expect(shouldRenderAsVue(widget)).toBe(true)
+ })
+
+ it('should handle case sensitivity correctly through aliases', () => {
+ // Test that both lowercase and uppercase work
+ expect(getComponent('string')).toBe(WidgetInputText)
+ expect(getComponent('STRING')).toBe(WidgetInputText)
+ expect(getComponent('combo')).toBe(WidgetSelect)
+ expect(getComponent('COMBO')).toBe(WidgetSelect)
+ })
+ })
+})
diff --git a/tests-ui/tests/utils/spatial/QuadTree.test.ts b/tests-ui/tests/utils/spatial/QuadTree.test.ts
new file mode 100644
index 0000000000..ea31e5682d
--- /dev/null
+++ b/tests-ui/tests/utils/spatial/QuadTree.test.ts
@@ -0,0 +1,269 @@
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree'
+
+describe('QuadTree', () => {
+ let quadTree: QuadTree
+ const worldBounds: Bounds = { x: 0, y: 0, width: 1000, height: 1000 }
+
+ beforeEach(() => {
+ quadTree = new QuadTree(worldBounds, {
+ maxDepth: 4,
+ maxItemsPerNode: 4
+ })
+ })
+
+ describe('insertion', () => {
+ it('should insert items within bounds', () => {
+ const success = quadTree.insert(
+ 'node1',
+ { x: 100, y: 100, width: 50, height: 50 },
+ 'node1'
+ )
+ expect(success).toBe(true)
+ expect(quadTree.size).toBe(1)
+ })
+
+ it('should reject items outside bounds', () => {
+ const success = quadTree.insert(
+ 'node1',
+ { x: -100, y: -100, width: 50, height: 50 },
+ 'node1'
+ )
+ expect(success).toBe(false)
+ expect(quadTree.size).toBe(0)
+ })
+
+ it('should handle duplicate IDs by replacing', () => {
+ quadTree.insert(
+ 'node1',
+ { x: 100, y: 100, width: 50, height: 50 },
+ 'data1'
+ )
+ quadTree.insert(
+ 'node1',
+ { x: 200, y: 200, width: 50, height: 50 },
+ 'data2'
+ )
+
+ expect(quadTree.size).toBe(1)
+ const results = quadTree.query({
+ x: 150,
+ y: 150,
+ width: 100,
+ height: 100
+ })
+ expect(results).toContain('data2')
+ expect(results).not.toContain('data1')
+ })
+ })
+
+ describe('querying', () => {
+ beforeEach(() => {
+ // Insert test nodes in a grid pattern
+ for (let x = 0; x < 10; x++) {
+ for (let y = 0; y < 10; y++) {
+ const id = `node_${x}_${y}`
+ quadTree.insert(
+ id,
+ {
+ x: x * 100,
+ y: y * 100,
+ width: 50,
+ height: 50
+ },
+ id
+ )
+ }
+ }
+ })
+
+ it('should find nodes within query bounds', () => {
+ const results = quadTree.query({ x: 0, y: 0, width: 250, height: 250 })
+ expect(results.length).toBe(9) // 3x3 grid
+ })
+
+ it('should return empty array for out-of-bounds query', () => {
+ const results = quadTree.query({
+ x: 2000,
+ y: 2000,
+ width: 100,
+ height: 100
+ })
+ expect(results.length).toBe(0)
+ })
+
+ it('should handle partial overlaps', () => {
+ const results = quadTree.query({ x: 25, y: 25, width: 100, height: 100 })
+ expect(results.length).toBe(4) // 2x2 grid due to overlap
+ })
+
+ it('should handle large query areas efficiently', () => {
+ const startTime = performance.now()
+ const results = quadTree.query({ x: 0, y: 0, width: 1000, height: 1000 })
+ const queryTime = performance.now() - startTime
+
+ expect(results.length).toBe(100) // All nodes
+ expect(queryTime).toBeLessThan(5) // Should be fast
+ })
+ })
+
+ describe('removal', () => {
+ it('should remove existing items', () => {
+ quadTree.insert(
+ 'node1',
+ { x: 100, y: 100, width: 50, height: 50 },
+ 'node1'
+ )
+ expect(quadTree.size).toBe(1)
+
+ const success = quadTree.remove('node1')
+ expect(success).toBe(true)
+ expect(quadTree.size).toBe(0)
+ })
+
+ it('should handle removal of non-existent items', () => {
+ const success = quadTree.remove('nonexistent')
+ expect(success).toBe(false)
+ })
+ })
+
+ describe('updating', () => {
+ it('should update item position', () => {
+ quadTree.insert(
+ 'node1',
+ { x: 100, y: 100, width: 50, height: 50 },
+ 'node1'
+ )
+
+ const success = quadTree.update('node1', {
+ x: 200,
+ y: 200,
+ width: 50,
+ height: 50
+ })
+ expect(success).toBe(true)
+
+ // Should not find at old position
+ const oldResults = quadTree.query({
+ x: 75,
+ y: 75,
+ width: 100,
+ height: 100
+ })
+ expect(oldResults).not.toContain('node1')
+
+ // Should find at new position
+ const newResults = quadTree.query({
+ x: 175,
+ y: 175,
+ width: 100,
+ height: 100
+ })
+ expect(newResults).toContain('node1')
+ })
+ })
+
+ describe('subdivision', () => {
+ it('should subdivide when exceeding max items', () => {
+ // Insert 5 items (max is 4) to trigger subdivision
+ for (let i = 0; i < 5; i++) {
+ quadTree.insert(
+ `node${i}`,
+ {
+ x: i * 10,
+ y: i * 10,
+ width: 5,
+ height: 5
+ },
+ `node${i}`
+ )
+ }
+
+ expect(quadTree.size).toBe(5)
+
+ // Verify all items can still be found
+ const allResults = quadTree.query(worldBounds)
+ expect(allResults.length).toBe(5)
+ })
+ })
+
+ describe('performance', () => {
+ it('should handle 1000 nodes efficiently', () => {
+ const insertStart = performance.now()
+
+ // Insert 1000 nodes
+ for (let i = 0; i < 1000; i++) {
+ const x = Math.random() * 900
+ const y = Math.random() * 900
+ quadTree.insert(
+ `node${i}`,
+ {
+ x,
+ y,
+ width: 50,
+ height: 50
+ },
+ `node${i}`
+ )
+ }
+
+ const insertTime = performance.now() - insertStart
+ expect(insertTime).toBeLessThan(50) // Should be fast
+
+ // Query performance
+ const queryStart = performance.now()
+ const results = quadTree.query({
+ x: 400,
+ y: 400,
+ width: 200,
+ height: 200
+ })
+ const queryTime = performance.now() - queryStart
+
+ expect(queryTime).toBeLessThan(2) // Queries should be very fast
+ expect(results.length).toBeGreaterThan(0)
+ expect(results.length).toBeLessThan(1000) // Should cull most nodes
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle zero-sized bounds', () => {
+ const success = quadTree.insert(
+ 'point',
+ { x: 100, y: 100, width: 0, height: 0 },
+ 'point'
+ )
+ expect(success).toBe(true)
+
+ const results = quadTree.query({ x: 99, y: 99, width: 2, height: 2 })
+ expect(results).toContain('point')
+ })
+
+ it('should handle items spanning multiple quadrants', () => {
+ const success = quadTree.insert(
+ 'large',
+ {
+ x: 400,
+ y: 400,
+ width: 200,
+ height: 200
+ },
+ 'large'
+ )
+ expect(success).toBe(true)
+
+ // Should be found when querying any overlapping quadrant
+ const topLeft = quadTree.query({ x: 0, y: 0, width: 500, height: 500 })
+ const bottomRight = quadTree.query({
+ x: 500,
+ y: 500,
+ width: 500,
+ height: 500
+ })
+
+ expect(topLeft).toContain('large')
+ expect(bottomRight).toContain('large')
+ })
+ })
+})