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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + 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 @@ + + + + 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 @@ + + + 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 @@ +