mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-04 19:49:09 +00:00
Compare commits
73 Commits
proxy-widg
...
bl-slots-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b163d17d78 | ||
|
|
c52c798817 | ||
|
|
f6ae1b6417 | ||
|
|
896e44f66b | ||
|
|
8e098fc325 | ||
|
|
85fa2f4559 | ||
|
|
07b7ed9385 | ||
|
|
817f4d036a | ||
|
|
6eeba70f10 | ||
|
|
7d8bdcb05a | ||
|
|
0aed837ff4 | ||
|
|
b7fd1f476c | ||
|
|
358d98e268 | ||
|
|
df36693ecb | ||
|
|
f6051f6634 | ||
|
|
e8dae57a6a | ||
|
|
1e307564f0 | ||
|
|
9ab075f11c | ||
|
|
3e5effeafc | ||
|
|
0b941583d2 | ||
|
|
7149af612a | ||
|
|
4fc89847ab | ||
|
|
2f512b847d | ||
|
|
b0f2a1d00a | ||
|
|
73a1feea99 | ||
|
|
7130794cba | ||
|
|
7b7f9bbb17 | ||
|
|
0f5315f24c | ||
|
|
2425f653e4 | ||
|
|
1480dd748a | ||
|
|
f99c9de72f | ||
|
|
2a64f538f3 | ||
|
|
6a3c075df1 | ||
|
|
c30f5a41a9 | ||
|
|
8a10387fdf | ||
|
|
32cffa6a83 | ||
|
|
1dbbf20124 | ||
|
|
f83801e998 | ||
|
|
969c8e6325 | ||
|
|
c6fc8e6f0f | ||
|
|
3ce3b67155 | ||
|
|
da042ae829 | ||
|
|
62096d46c1 | ||
|
|
2a5e0d231e | ||
|
|
08309595e0 | ||
|
|
3982f29fda | ||
|
|
57db10f408 | ||
|
|
1447b15f4c | ||
|
|
4a7c955a15 | ||
|
|
ba1fa1be25 | ||
|
|
5171decd8b | ||
|
|
5a74c019c7 | ||
|
|
934c650790 | ||
|
|
b2a828dda6 | ||
|
|
d1ed5ecc0d | ||
|
|
bfcbcf4873 | ||
|
|
0dd4ff2087 | ||
|
|
889d136154 | ||
|
|
c773230b21 | ||
|
|
8df41ab040 | ||
|
|
2b9a9e2371 | ||
|
|
4171219402 | ||
|
|
301355e018 | ||
|
|
ac17752c0b | ||
|
|
fc6943cdd3 | ||
|
|
9db96f2dcc | ||
|
|
19084e2799 | ||
|
|
6e04cb72b0 | ||
|
|
9c4d782b30 | ||
|
|
ac60d1ceb4 | ||
|
|
06d0a63a2f | ||
|
|
2dcfe84e99 | ||
|
|
f42a4dd6cc |
@@ -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: `<div :class="cn('bg-red-500', { 'bg-blue-500': condition })" />`
|
||||
|
||||
|
||||
131
browser_tests/fixtures/utils/vueNodeFixtures.ts
Normal file
131
browser_tests/fixtures/utils/vueNodeFixtures.ts
Normal file
@@ -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<Locator> {
|
||||
const nodeId = this.nodeRef.id
|
||||
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's title element
|
||||
*/
|
||||
async getTitleElement(): Promise<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-title"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current title text
|
||||
*/
|
||||
async getTitle(): Promise<string> {
|
||||
const titleElement = await this.getTitleElement()
|
||||
return (await titleElement.textContent()) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a new title by double-clicking and entering text
|
||||
*/
|
||||
async setTitle(newTitle: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<Locator> {
|
||||
const header = await this.getHeader()
|
||||
return header.locator('[data-testid="node-collapse-button"]')
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the node's collapsed state
|
||||
*/
|
||||
async toggleCollapse(): Promise<void> {
|
||||
const button = await this.getCollapseButton()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon element
|
||||
*/
|
||||
async getCollapseIcon(): Promise<Locator> {
|
||||
const button = await this.getCollapseButton()
|
||||
return button.locator('i')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the collapse icon's CSS classes
|
||||
*/
|
||||
async getCollapseIconClass(): Promise<string> {
|
||||
const icon = await this.getCollapseIcon()
|
||||
return (await icon.getAttribute('class')) || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the collapse button is visible
|
||||
*/
|
||||
async isCollapseButtonVisible(): Promise<boolean> {
|
||||
const button = await this.getCollapseButton()
|
||||
return await button.isVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node's body/content element
|
||||
*/
|
||||
async getBody(): Promise<Locator> {
|
||||
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<boolean> {
|
||||
const body = await this.getBody()
|
||||
return await body.isVisible()
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 177 KiB |
134
browser_tests/tests/vueNodes/NodeHeader.spec.ts
Normal file
134
browser_tests/tests/vueNodes/NodeHeader.spec.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
fluid
|
||||
:pt="{
|
||||
root: {
|
||||
onBlur: finishEditing
|
||||
onBlur: finishEditing,
|
||||
...inputAttrs
|
||||
}
|
||||
}"
|
||||
@keyup.enter="blurInputElement"
|
||||
@keyup.escape="cancelEditing"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
@@ -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<string, any>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'edit'])
|
||||
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
|
||||
const inputValue = ref<string>(modelValue)
|
||||
const inputRef = ref<InstanceType<typeof InputText> | 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,
|
||||
|
||||
@@ -31,6 +31,35 @@
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
<TransformPane
|
||||
v-if="isVueNodesEnabled && canvasStore.canvas && comfyAppReady"
|
||||
:canvas="canvasStore.canvas as LGraphCanvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
>
|
||||
<!-- Vue nodes rendered based on graph nodes -->
|
||||
<VueGraphNode
|
||||
v-for="nodeData in nodesToRender"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:selected="nodeData.selected"
|
||||
:readonly="false"
|
||||
:executing="executionStore.executingNodeId === nodeData.id"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
? 'Execution error'
|
||||
: null
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@update:collapsed="handleNodeCollapse"
|
||||
@update:title="handleNodeTitleUpdate"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -39,13 +68,22 @@
|
||||
<template v-if="comfyAppReady">
|
||||
<TitleEditor />
|
||||
<SelectionToolbox v-if="selectionToolboxEnabled" />
|
||||
<DomWidgets />
|
||||
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
|
||||
<DomWidgets v-if="!shouldRenderVueNodes" />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useEventListener, whenever } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -55,10 +93,17 @@ import MiniMap from '@/components/graph/MiniMap.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import TransformPane from '@/components/graph/TransformPane.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useNodeBadge } from '@/composables/node/useNodeBadge'
|
||||
import { useCanvasDrop } from '@/composables/useCanvasDrop'
|
||||
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
|
||||
@@ -66,11 +111,19 @@ import { useCopy } from '@/composables/useCopy'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
|
||||
import { CORE_SETTINGS } from '@/constants/coreSettings'
|
||||
import { i18n, t } from '@/i18n'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
|
||||
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -102,6 +155,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -118,6 +172,332 @@ const selectionToolboxEnabled = computed(() =>
|
||||
|
||||
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags (Vue-related)
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
|
||||
|
||||
// Vue node lifecycle management - initialize after graph is ready
|
||||
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
|
||||
let cleanupNodeManager: (() => void) | null = null
|
||||
|
||||
// Slot layout sync management
|
||||
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
|
||||
let slotSyncStarted = false
|
||||
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
|
||||
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
|
||||
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
|
||||
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
|
||||
new Map()
|
||||
)
|
||||
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
|
||||
new Map()
|
||||
)
|
||||
let detectChangesInRAF = () => {}
|
||||
|
||||
// Initialize node manager when graph becomes available
|
||||
// Add a reactivity trigger to force computed re-evaluation
|
||||
const nodeDataTrigger = ref(0)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
if (!comfyApp.graph || nodeManager) return
|
||||
nodeManager = useGraphNodeManager(comfyApp.graph)
|
||||
cleanupNodeManager = nodeManager.cleanup
|
||||
// Use the manager's reactive maps directly
|
||||
vueNodeData.value = nodeManager.vueNodeData
|
||||
nodeState.value = nodeManager.nodeState
|
||||
nodePositions.value = nodeManager.nodePositions
|
||||
nodeSizes.value = nodeManager.nodeSizes
|
||||
detectChangesInRAF = nodeManager.detectChangesInRAF
|
||||
|
||||
// Initialize layout system with existing nodes
|
||||
const nodes = comfyApp.graph._nodes.map((node: any) => ({
|
||||
id: node.id.toString(),
|
||||
pos: node.pos,
|
||||
size: node.size
|
||||
}))
|
||||
layoutStore.initializeFromLiteGraph(nodes)
|
||||
|
||||
// Seed reroutes into the Layout Store so hit-testing uses the new path
|
||||
for (const reroute of comfyApp.graph.reroutes.values()) {
|
||||
const [x, y] = reroute.pos
|
||||
const parent = reroute.parentId ?? undefined
|
||||
const linkIds = Array.from(reroute.linkIds)
|
||||
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
|
||||
}
|
||||
|
||||
// Seed existing links into the Layout Store (topology only)
|
||||
for (const link of comfyApp.graph._links.values()) {
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
link.origin_id,
|
||||
link.origin_slot,
|
||||
link.target_id,
|
||||
link.target_slot
|
||||
)
|
||||
}
|
||||
|
||||
// Initialize layout sync (one-way: Layout Store → LiteGraph)
|
||||
const { startSync } = useLayoutSync()
|
||||
startSync(canvasStore.canvas)
|
||||
|
||||
// Initialize link layout sync for event-driven updates
|
||||
linkSync = useLinkLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
linkSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
}
|
||||
|
||||
// Force computed properties to re-evaluate
|
||||
nodeDataTrigger.value++
|
||||
}
|
||||
|
||||
const disposeNodeManagerAndSyncs = () => {
|
||||
if (!nodeManager) return
|
||||
try {
|
||||
cleanupNodeManager?.()
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
nodeManager = null
|
||||
cleanupNodeManager = null
|
||||
|
||||
// Clean up link layout sync
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
linkSync = null
|
||||
}
|
||||
|
||||
// Reset reactive maps to inert defaults
|
||||
vueNodeData.value = new Map()
|
||||
nodeState.value = new Map()
|
||||
nodePositions.value = new Map()
|
||||
nodeSizes.value = new Map()
|
||||
}
|
||||
|
||||
// Watch for transformPaneEnabled to gate the node manager lifecycle
|
||||
watch(
|
||||
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
} else {
|
||||
disposeNodeManagerAndSyncs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Ensure slot layout sync starts whenever a canvas is available (LiteGraph mode)
|
||||
watch(
|
||||
() => canvasStore.canvas,
|
||||
(canvas, oldCanvas) => {
|
||||
if (!canvas) {
|
||||
// Canvas was removed - stop sync if active
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
// Clear any stale slot layouts when canvas is torn down
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
return
|
||||
}
|
||||
|
||||
// Canvas changed - restart sync
|
||||
if (oldCanvas && oldCanvas !== canvas) {
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
// Start sync if not in Vue mode and not already started
|
||||
if (!slotSync) slotSync = useSlotLayoutSync()
|
||||
if (!slotSyncStarted && !isVueNodesEnabled.value) {
|
||||
const started = slotSync.start(canvas as LGraphCanvas)
|
||||
slotSyncStarted = started
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// On rendering mode change, clear slot layouts and manage slot sync
|
||||
watch(
|
||||
() => isVueNodesEnabled.value,
|
||||
(enabled) => {
|
||||
// Always clear invalid slot layouts from the prior mode
|
||||
layoutStore.clearAllSlotLayouts()
|
||||
|
||||
if (enabled) {
|
||||
// Switching TO Vue: Stop slot sync to avoid duplicate registration
|
||||
if (slotSync && slotSyncStarted) {
|
||||
slotSync.stop()
|
||||
slotSyncStarted = false
|
||||
}
|
||||
// DOM will re-register via useDomSlotRegistration
|
||||
} else {
|
||||
// Switching TO LiteGraph
|
||||
if (canvasStore.canvas && comfyApp.graph) {
|
||||
// Ensure slot sync is active
|
||||
if (!slotSync) slotSync = useSlotLayoutSync()
|
||||
if (!slotSyncStarted) {
|
||||
const started = slotSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
slotSyncStarted = started
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
// Transform state for viewport culling
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
const nodesToRender = computed(() => {
|
||||
// Early return for zero overhead when Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph) {
|
||||
return []
|
||||
}
|
||||
|
||||
const allNodes = Array.from(vueNodeData.value.values())
|
||||
|
||||
// Apply viewport culling - check if node bounds intersect with viewport
|
||||
if (nodeManager && canvasStore.canvas && comfyApp.canvas) {
|
||||
const canvas = canvasStore.canvas
|
||||
const manager = nodeManager
|
||||
|
||||
// Ensure transform is synced before checking visibility
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
|
||||
const ds = canvas.ds
|
||||
|
||||
// Work in screen space - viewport is simply the canvas element size
|
||||
const viewport_width = canvas.canvas.width
|
||||
const viewport_height = canvas.canvas.height
|
||||
|
||||
// Add margin that represents a constant distance in canvas space
|
||||
// Convert canvas units to screen pixels by multiplying by scale
|
||||
const canvasMarginDistance = 200 // Fixed margin in canvas units
|
||||
const margin_x = canvasMarginDistance * ds.scale
|
||||
const margin_y = canvasMarginDistance * ds.scale
|
||||
|
||||
const filtered = allNodes.filter((nodeData) => {
|
||||
const node = manager.getNode(nodeData.id)
|
||||
if (!node) return false
|
||||
|
||||
// Transform node position to screen space (same as DOM widgets)
|
||||
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
|
||||
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
|
||||
const screen_width = node.size[0] * ds.scale
|
||||
const screen_height = node.size[1] * ds.scale
|
||||
|
||||
// Check if node bounds intersect with expanded viewport (in screen space)
|
||||
const isVisible = !(
|
||||
screen_x + screen_width < -margin_x ||
|
||||
screen_x > viewport_width + margin_x ||
|
||||
screen_y + screen_height < -margin_y ||
|
||||
screen_y > viewport_height + margin_y
|
||||
)
|
||||
|
||||
return isVisible
|
||||
})
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
return allNodes
|
||||
})
|
||||
|
||||
let lastScale = 1
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
// Skip all work if Vue nodes are disabled
|
||||
if (!isVueNodesEnabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// Sync transform state only when it changes (avoids reflows)
|
||||
if (comfyApp.canvas?.ds) {
|
||||
const currentScale = comfyApp.canvas.ds.scale
|
||||
const currentOffsetX = comfyApp.canvas.ds.offset[0]
|
||||
const currentOffsetY = comfyApp.canvas.ds.offset[1]
|
||||
|
||||
if (
|
||||
currentScale !== lastScale ||
|
||||
currentOffsetX !== lastOffsetX ||
|
||||
currentOffsetY !== lastOffsetY
|
||||
) {
|
||||
syncWithCanvas(comfyApp.canvas)
|
||||
lastScale = currentScale
|
||||
lastOffsetX = currentOffsetX
|
||||
lastOffsetY = currentOffsetY
|
||||
}
|
||||
}
|
||||
|
||||
// Detect node changes during transform updates
|
||||
detectChangesInRAF()
|
||||
|
||||
// Trigger reactivity for nodesToRender
|
||||
void nodesToRender.value.length
|
||||
}
|
||||
|
||||
// Node event handlers
|
||||
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
|
||||
if (!canvasStore.canvas || !nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeData.id)
|
||||
if (!node) return
|
||||
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
canvasStore.canvas.deselectAllNodes()
|
||||
}
|
||||
|
||||
canvasStore.canvas.selectNode(node)
|
||||
|
||||
// Bring node to front when clicked (similar to LiteGraph behavior)
|
||||
// Skip if node is pinned
|
||||
if (!node.flags?.pinned) {
|
||||
layoutMutations.setSource(LayoutSource.Vue)
|
||||
layoutMutations.bringNodeToFront(nodeData.id)
|
||||
}
|
||||
node.selected = true
|
||||
|
||||
canvasStore.updateSelectedItems()
|
||||
}
|
||||
|
||||
// Handle node collapse state changes
|
||||
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
|
||||
if (!nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Use LiteGraph's collapse method if the state needs to change
|
||||
const currentCollapsed = node.flags?.collapsed ?? false
|
||||
if (currentCollapsed !== collapsed) {
|
||||
node.collapse()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle node title updates
|
||||
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
|
||||
if (!nodeManager) return
|
||||
|
||||
const node = nodeManager.getNode(nodeId)
|
||||
if (!node) return
|
||||
|
||||
// Update the node title in LiteGraph for persistence
|
||||
node.title = newTitle
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
})
|
||||
@@ -286,6 +666,7 @@ onMounted(async () => {
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useVueFeatureFlags()
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
@@ -298,9 +679,6 @@ onMounted(async () => {
|
||||
await settingStore.loadSettingValues()
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
console.log(
|
||||
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
|
||||
)
|
||||
localStorage.removeItem('Comfy.userId')
|
||||
localStorage.removeItem('Comfy.userName')
|
||||
window.location.reload()
|
||||
@@ -326,6 +704,30 @@ onMounted(async () => {
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
// Set up Vue node initialization only when enabled
|
||||
if (isVueNodesEnabled.value) {
|
||||
// Set up a one-time listener for when the first node is added
|
||||
// This handles the case where Vue nodes are enabled but the graph starts empty
|
||||
// TODO: Replace this with a reactive graph mutations observer when available
|
||||
if (comfyApp.graph && !nodeManager && comfyApp.graph._nodes.length === 0) {
|
||||
const originalOnNodeAdded = comfyApp.graph.onNodeAdded
|
||||
comfyApp.graph.onNodeAdded = function (node: any) {
|
||||
// Restore original handler
|
||||
comfyApp.graph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (isVueNodesEnabled.value && !nodeManager) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
// Call original handler
|
||||
if (originalOnNodeAdded) {
|
||||
originalOnNodeAdded.call(this, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -366,4 +768,20 @@ onMounted(async () => {
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (nodeManager) {
|
||||
nodeManager.cleanup()
|
||||
nodeManager = null
|
||||
}
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
slotSyncStarted = false
|
||||
}
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
linkSync = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
350
src/components/graph/TransformPane.spec.ts
Normal file
350
src/components/graph/TransformPane.spec.ts
Normal file
@@ -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<typeof mount>
|
||||
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: '<div class="test-content">Test Node</div>'
|
||||
}
|
||||
})
|
||||
|
||||
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: '<div data-node-id="test">Test Node</div>'
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
91
src/components/graph/TransformPane.vue
Normal file
91
src/components/graph/TransformPane.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { useTransformState } from '@/composables/element/useTransformState'
|
||||
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
|
||||
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
provide('transformState', {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
const nodeElement = target.closest('[data-node-id]')
|
||||
|
||||
if (nodeElement) {
|
||||
// TODO: Emit event for node interaction
|
||||
// Node interaction with nodeId will be handled in future implementation
|
||||
}
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
rafStatusChange: [active: boolean]
|
||||
transformUpdate: [time: number]
|
||||
}>()
|
||||
|
||||
useCanvasTransformSync(props.canvas, syncWithCanvas, {
|
||||
onStart: () => emit('rafStatusChange', true),
|
||||
onUpdate: (duration) => emit('transformUpdate', duration),
|
||||
onStop: () => emit('rafStatusChange', false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.transform-pane {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.transform-pane--interacting {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Allow pointer events on nodes */
|
||||
.transform-pane :deep([data-node-id]) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
242
src/composables/element/useTransformState.ts
Normal file
242
src/composables/element/useTransformState.ts
Normal file
@@ -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
|
||||
* <div :style="transformStyle">
|
||||
* <NodeComponent
|
||||
* v-for="node in nodes"
|
||||
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
|
||||
* />
|
||||
* </div>
|
||||
*
|
||||
* // 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<Camera>({
|
||||
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<number>,
|
||||
size: ArrayLike<number>
|
||||
): 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<number>): 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<number>,
|
||||
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<number>,
|
||||
nodeSize: ArrayLike<number>,
|
||||
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
|
||||
}
|
||||
}
|
||||
115
src/composables/graph/useCanvasTransformSync.ts
Normal file
115
src/composables/graph/useCanvasTransformSync.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
813
src/composables/graph/useGraphNodeManager.ts
Normal file
813
src/composables/graph/useGraphNodeManager.ts
Normal file
@@ -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<string>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
nodePositions: ReadonlyMap<string, { x: number; y: number }>
|
||||
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
|
||||
|
||||
// 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<string>
|
||||
|
||||
// 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<string, VueNodeData>())
|
||||
const nodeState = reactive(new Map<string, NodeState>())
|
||||
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
|
||||
const nodeSizes = reactive(
|
||||
new Map<string, { width: number; height: number }>()
|
||||
)
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// WeakMap for heavy data that auto-GCs when nodes are removed
|
||||
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
|
||||
|
||||
// Performance tracking
|
||||
const performanceMetrics = reactive<PerformanceMetrics>({
|
||||
fps: 0,
|
||||
frameTime: 0,
|
||||
updateTime: 0,
|
||||
nodeCount: 0,
|
||||
culledCount: 0,
|
||||
callbackUpdateCount: 0,
|
||||
rafUpdateCount: 0,
|
||||
adaptiveQuality: false
|
||||
})
|
||||
|
||||
// Spatial indexing using QuadTree
|
||||
const spatialIndex = new QuadTree<string>(
|
||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||
)
|
||||
let lastSpatialQueryTime = 0
|
||||
|
||||
// Spatial metrics
|
||||
const spatialMetrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
nodesInIndex: 0
|
||||
})
|
||||
|
||||
// Update batching
|
||||
const pendingUpdates = new Set<string>()
|
||||
const criticalUpdates = new Set<string>()
|
||||
const lowPriorityUpdates = new Set<string>()
|
||||
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<string> => {
|
||||
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<string, VueNodeData>,
|
||||
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
198
src/composables/graph/useSpatialIndex.ts
Normal file
198
src/composables/graph/useSpatialIndex.ts
Normal file
@@ -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<QuadTree<string> | null>(null)
|
||||
|
||||
// Performance metrics
|
||||
const metrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
totalNodes: 0,
|
||||
visibleNodes: 0,
|
||||
treeDepth: 0,
|
||||
rebuildCount: 0
|
||||
})
|
||||
|
||||
// Initialize QuadTree
|
||||
const initialize = (bounds: Bounds = defaultBounds) => {
|
||||
quadTree.value = new QuadTree<string>(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)
|
||||
}
|
||||
}
|
||||
151
src/composables/graph/useTransformSettling.ts
Normal file
151
src/composables/graph/useTransformSettling.ts
Normal file
@@ -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<HTMLElement | null | undefined>,
|
||||
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
|
||||
}
|
||||
}
|
||||
155
src/composables/graph/useWidgetValue.ts
Normal file
155
src/composables/graph/useWidgetValue.ts
Normal file
@@ -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<T>
|
||||
/** 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<T>
|
||||
/** 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<T extends WidgetValue = WidgetValue, U = T>({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue,
|
||||
emit,
|
||||
transform
|
||||
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
|
||||
// Local value for immediate UI updates
|
||||
const localValue = ref<T>(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<T>,
|
||||
onChange
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-specific helper for string widgets
|
||||
*/
|
||||
export function useStringWidgetValue(
|
||||
widget: SimplifiedWidget<string>,
|
||||
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<number>,
|
||||
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<boolean>,
|
||||
modelValue: boolean,
|
||||
emit: (event: 'update:modelValue', value: boolean) => void
|
||||
) {
|
||||
return useWidgetValue({
|
||||
widget,
|
||||
modelValue,
|
||||
defaultValue: false,
|
||||
emit,
|
||||
transform: (value: boolean) => Boolean(value)
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = <T = unknown>(featurePath: string, defaultValue?: T) => {
|
||||
return computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
}
|
||||
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
|
||||
computed(() => api.getServerFeature(featurePath, defaultValue))
|
||||
|
||||
return {
|
||||
flags: readonly(flags),
|
||||
|
||||
38
src/composables/useVueFeatureFlags.ts
Normal file
38
src/composables/useVueFeatureFlags.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
|
||||
30
src/constants/slotColors.ts
Normal file
30
src/constants/slotColors.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Default colors for node slot types
|
||||
* Mirrors LiteGraph's slot_default_color_by_type
|
||||
*/
|
||||
export const SLOT_TYPE_COLORS: Record<string, string> = {
|
||||
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['*']
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
getGroupData() {
|
||||
this.groupNodeType = LiteGraph.registered_node_types[
|
||||
`${PREFIX}${SEPARATOR}` + this.selectedGroup
|
||||
] as LGraphNodeConstructor<LGraphNode>
|
||||
] as unknown as LGraphNodeConstructor<LGraphNode>
|
||||
this.groupNodeDef = this.groupNodeType.nodeData
|
||||
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@ import { toString } from 'es-toolkit/compat'
|
||||
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
|
||||
import {
|
||||
type LinkRenderContext,
|
||||
LitegraphLinkAdapter
|
||||
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
|
||||
import { CanvasPointer } from './CanvasPointer'
|
||||
import type { ContextMenu } from './ContextMenu'
|
||||
@@ -51,7 +56,6 @@ import {
|
||||
containsRect,
|
||||
createBounds,
|
||||
distance,
|
||||
findPointOnCurve,
|
||||
isInRect,
|
||||
isInRectangle,
|
||||
isPointInRect,
|
||||
@@ -235,9 +239,6 @@ export class LGraphCanvas
|
||||
static #tmp_area = new Float32Array(4)
|
||||
static #margin_area = new Float32Array(4)
|
||||
static #link_bounding = new Float32Array(4)
|
||||
static #lTempA: Point = new Float32Array(2)
|
||||
static #lTempB: Point = new Float32Array(2)
|
||||
static #lTempC: Point = new Float32Array(2)
|
||||
|
||||
static DEFAULT_BACKGROUND_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
|
||||
@@ -679,6 +680,9 @@ export class LGraphCanvas
|
||||
/** Set on keydown, keyup. @todo */
|
||||
#shiftDown: boolean = false
|
||||
|
||||
/** Link rendering adapter for litegraph-to-canvas integration */
|
||||
linkRenderer: LitegraphLinkAdapter | null = null
|
||||
|
||||
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
|
||||
dragZoomEnabled: boolean = false
|
||||
/** The start position of the drag zoom. */
|
||||
@@ -748,6 +752,13 @@ export class LGraphCanvas
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize link renderer if graph is available
|
||||
if (graph) {
|
||||
this.linkRenderer = new LitegraphLinkAdapter(graph)
|
||||
// Disable layout writes during render
|
||||
this.linkRenderer.enableLayoutStoreWrites = false
|
||||
}
|
||||
|
||||
this.linkConnector.events.addEventListener('link-created', () =>
|
||||
this.#dirty()
|
||||
)
|
||||
@@ -1843,6 +1854,11 @@ export class LGraphCanvas
|
||||
this.clear()
|
||||
newGraph.attachCanvas(this)
|
||||
|
||||
// Re-initialize link renderer with new graph
|
||||
this.linkRenderer = new LitegraphLinkAdapter(newGraph)
|
||||
// Disable layout writes during render
|
||||
this.linkRenderer.enableLayoutStoreWrites = false
|
||||
|
||||
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
|
||||
this.#dirty()
|
||||
}
|
||||
@@ -2236,11 +2252,22 @@ export class LGraphCanvas
|
||||
this.processSelect(node, e, true)
|
||||
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
||||
// Reroutes
|
||||
const reroute = graph.getRerouteOnPos(
|
||||
e.canvasX,
|
||||
e.canvasY,
|
||||
this.#visibleReroutes
|
||||
)
|
||||
// Try layout store first, fallback to old method
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
||||
x: e.canvasX,
|
||||
y: e.canvasY
|
||||
})
|
||||
|
||||
let reroute: Reroute | undefined
|
||||
if (rerouteLayout) {
|
||||
reroute = graph.getReroute(rerouteLayout.id)
|
||||
} else {
|
||||
reroute = graph.getRerouteOnPos(
|
||||
e.canvasX,
|
||||
e.canvasY,
|
||||
this.#visibleReroutes
|
||||
)
|
||||
}
|
||||
if (reroute) {
|
||||
if (e.altKey) {
|
||||
pointer.onClick = (upEvent) => {
|
||||
@@ -2406,8 +2433,18 @@ export class LGraphCanvas
|
||||
|
||||
// Reroutes
|
||||
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
||||
// Try layout store first for hit detection
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({ x, y })
|
||||
let foundReroute: Reroute | undefined
|
||||
|
||||
if (rerouteLayout) {
|
||||
foundReroute = graph.getReroute(rerouteLayout.id)
|
||||
}
|
||||
|
||||
// Fallback to checking visible reroutes directly
|
||||
for (const reroute of this.#visibleReroutes) {
|
||||
const overReroute = reroute.containsPoint([x, y])
|
||||
const overReroute =
|
||||
foundReroute === reroute || reroute.containsPoint([x, y])
|
||||
if (!reroute.isSlotHovered && !overReroute) continue
|
||||
|
||||
if (overReroute) {
|
||||
@@ -2441,16 +2478,32 @@ export class LGraphCanvas
|
||||
this.ctx.lineWidth = this.connections_width + 7
|
||||
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
|
||||
|
||||
// Try layout store for segment hit testing first (more precise)
|
||||
const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
if (!centre) continue
|
||||
|
||||
// Check if this link segment was hit
|
||||
let isLinkHit =
|
||||
hitSegment &&
|
||||
linkSegment.id ===
|
||||
(linkSegment instanceof Reroute
|
||||
? hitSegment.rerouteId
|
||||
: hitSegment.linkId)
|
||||
|
||||
if (!isLinkHit && linkSegment.path) {
|
||||
// Fallback to direct path hit testing if not found in layout store
|
||||
isLinkHit = this.ctx.isPointInStroke(
|
||||
linkSegment.path,
|
||||
x * dpi,
|
||||
y * dpi
|
||||
)
|
||||
}
|
||||
|
||||
// If we shift click on a link then start a link from that input
|
||||
if (
|
||||
(e.shiftKey || e.altKey) &&
|
||||
linkSegment.path &&
|
||||
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
|
||||
) {
|
||||
if ((e.shiftKey || e.altKey) && isLinkHit) {
|
||||
this.ctx.lineWidth = lineWidth
|
||||
|
||||
if (e.shiftKey && !e.altKey) {
|
||||
@@ -2465,7 +2518,10 @@ export class LGraphCanvas
|
||||
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
|
||||
return
|
||||
}
|
||||
} else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) {
|
||||
} else if (
|
||||
this.linkMarkerShape !== LinkMarkerShape.None &&
|
||||
isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)
|
||||
) {
|
||||
this.ctx.lineWidth = lineWidth
|
||||
|
||||
pointer.onClick = () => this.showLinkMenu(linkSegment, e)
|
||||
@@ -3178,8 +3234,27 @@ export class LGraphCanvas
|
||||
// For input/output hovering
|
||||
// to store the output of isOverNodeInput
|
||||
const pos: Point = [0, 0]
|
||||
const inputId = isOverNodeInput(node, x, y, pos)
|
||||
const outputId = isOverNodeOutput(node, x, y, pos)
|
||||
|
||||
// Try to use layout store for hit testing first, fallback to old method
|
||||
let inputId: number = -1
|
||||
let outputId: number = -1
|
||||
|
||||
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
|
||||
if (slotLayout && slotLayout.nodeId === String(node.id)) {
|
||||
if (slotLayout.type === 'input') {
|
||||
inputId = slotLayout.index
|
||||
pos[0] = slotLayout.position.x
|
||||
pos[1] = slotLayout.position.y
|
||||
} else {
|
||||
outputId = slotLayout.index
|
||||
pos[0] = slotLayout.position.x
|
||||
pos[1] = slotLayout.position.y
|
||||
}
|
||||
} else {
|
||||
// Fallback to old method
|
||||
inputId = isOverNodeInput(node, x, y, pos)
|
||||
outputId = isOverNodeOutput(node, x, y, pos)
|
||||
}
|
||||
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
|
||||
|
||||
if (!node.mouseOver) {
|
||||
@@ -4640,18 +4715,28 @@ export class LGraphCanvas
|
||||
: LiteGraph.CONNECTING_LINK_COLOR
|
||||
|
||||
// the connection being dragged by the mouse
|
||||
this.renderLink(
|
||||
ctx,
|
||||
pos,
|
||||
highlightPos,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
colour,
|
||||
fromDirection,
|
||||
dragDirection
|
||||
)
|
||||
if (this.linkRenderer) {
|
||||
this.linkRenderer.renderLinkDirect(
|
||||
ctx,
|
||||
pos,
|
||||
highlightPos,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
colour,
|
||||
fromDirection,
|
||||
dragDirection,
|
||||
{
|
||||
...this.buildLinkRenderContext(),
|
||||
linkMarkerShape: LinkMarkerShape.None
|
||||
},
|
||||
{
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ctx.fillStyle = colour
|
||||
ctx.beginPath()
|
||||
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
|
||||
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
|
||||
@@ -4724,6 +4809,11 @@ export class LGraphCanvas
|
||||
|
||||
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
|
||||
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
|
||||
// Skip hit detection if center markers are disabled
|
||||
if (this.linkMarkerShape === LinkMarkerShape.None) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const linkSegment of this.renderedPaths) {
|
||||
const centre = linkSegment._pos
|
||||
if (!centre) continue
|
||||
@@ -5049,6 +5139,19 @@ export class LGraphCanvas
|
||||
drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void {
|
||||
this.current_node = node
|
||||
|
||||
// When Vue nodes mode is enabled, LiteGraph should not draw node chrome or widgets.
|
||||
// We still need to keep slot metrics and layout in sync for hit-testing and links.
|
||||
// Interaction system changes coming later, chances are vue nodes mode will be mostly broken on land
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Prepare concrete slots and compute layout measures without rendering visuals.
|
||||
node._setConcreteSlots()
|
||||
if (!node.collapsed) {
|
||||
node.arrange()
|
||||
}
|
||||
// Skip all node body/widget/title rendering. Vue overlay handles visuals.
|
||||
return
|
||||
}
|
||||
|
||||
const color = node.renderingColor
|
||||
const bgcolor = node.renderingBgColor
|
||||
|
||||
@@ -5762,6 +5865,34 @@ export class LGraphCanvas
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build LinkRenderContext from canvas properties
|
||||
* Helper method for using LitegraphLinkAdapter
|
||||
*/
|
||||
private buildLinkRenderContext(): LinkRenderContext {
|
||||
return {
|
||||
// Canvas settings
|
||||
renderMode: this.links_render_mode,
|
||||
connectionWidth: this.connections_width,
|
||||
renderBorder: this.render_connections_border,
|
||||
lowQuality: this.low_quality,
|
||||
highQualityRender: this.highquality_render,
|
||||
scale: this.ds.scale,
|
||||
linkMarkerShape: this.linkMarkerShape,
|
||||
renderConnectionArrows: this.render_connection_arrows,
|
||||
|
||||
// State
|
||||
highlightedLinks: new Set(Object.keys(this.highlighted_links)),
|
||||
|
||||
// Colors
|
||||
defaultLinkColor: this.default_link_color,
|
||||
linkTypeColors: LGraphCanvas.link_type_colors,
|
||||
|
||||
// Pattern for disabled links
|
||||
disabledPattern: this._pattern
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* draws a link between two points
|
||||
* @param ctx Canvas 2D rendering context
|
||||
@@ -5803,333 +5934,27 @@ export class LGraphCanvas
|
||||
disabled?: boolean
|
||||
} = {}
|
||||
): void {
|
||||
const linkColour =
|
||||
link != null && this.highlighted_links[link.id]
|
||||
? '#FFF'
|
||||
: color ||
|
||||
link?.color ||
|
||||
(link?.type != null && LGraphCanvas.link_type_colors[link.type]) ||
|
||||
this.default_link_color
|
||||
const startDir = start_dir || LinkDirection.RIGHT
|
||||
const endDir = end_dir || LinkDirection.LEFT
|
||||
|
||||
const dist =
|
||||
this.links_render_mode == LinkRenderType.SPLINE_LINK &&
|
||||
(!endControl || !startControl)
|
||||
? distance(a, b)
|
||||
: 0
|
||||
|
||||
// TODO: Subline code below was inserted in the wrong place - should be before this statement
|
||||
if (this.render_connections_border && !this.low_quality) {
|
||||
ctx.lineWidth = this.connections_width + 4
|
||||
}
|
||||
ctx.lineJoin = 'round'
|
||||
num_sublines ||= 1
|
||||
if (num_sublines > 1) ctx.lineWidth = 0.5
|
||||
|
||||
// begin line shape
|
||||
const path = new Path2D()
|
||||
|
||||
/** The link or reroute we're currently rendering */
|
||||
const linkSegment = reroute ?? link
|
||||
if (linkSegment) linkSegment.path = path
|
||||
|
||||
const innerA = LGraphCanvas.#lTempA
|
||||
const innerB = LGraphCanvas.#lTempB
|
||||
|
||||
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
|
||||
const pos: Point = linkSegment?._pos ?? [0, 0]
|
||||
|
||||
for (let i = 0; i < num_sublines; i++) {
|
||||
const offsety = (i - (num_sublines - 1) * 0.5) * 5
|
||||
innerA[0] = a[0]
|
||||
innerA[1] = a[1]
|
||||
innerB[0] = b[0]
|
||||
innerB[1] = b[1]
|
||||
|
||||
if (this.links_render_mode == LinkRenderType.SPLINE_LINK) {
|
||||
if (endControl) {
|
||||
innerB[0] = b[0] + endControl[0]
|
||||
innerB[1] = b[1] + endControl[1]
|
||||
} else {
|
||||
this.#addSplineOffset(innerB, endDir, dist)
|
||||
if (this.linkRenderer) {
|
||||
const context = this.buildLinkRenderContext()
|
||||
this.linkRenderer.renderLinkDirect(
|
||||
ctx,
|
||||
a,
|
||||
b,
|
||||
link,
|
||||
skip_border,
|
||||
flow,
|
||||
color,
|
||||
start_dir,
|
||||
end_dir,
|
||||
context,
|
||||
{
|
||||
reroute,
|
||||
startControl,
|
||||
endControl,
|
||||
num_sublines,
|
||||
disabled
|
||||
}
|
||||
if (startControl) {
|
||||
innerA[0] = a[0] + startControl[0]
|
||||
innerA[1] = a[1] + startControl[1]
|
||||
} else {
|
||||
this.#addSplineOffset(innerA, startDir, dist)
|
||||
}
|
||||
path.moveTo(a[0], a[1] + offsety)
|
||||
path.bezierCurveTo(
|
||||
innerA[0],
|
||||
innerA[1] + offsety,
|
||||
innerB[0],
|
||||
innerB[1] + offsety,
|
||||
b[0],
|
||||
b[1] + offsety
|
||||
)
|
||||
|
||||
// Calculate centre point
|
||||
findPointOnCurve(pos, a, b, innerA, innerB, 0.5)
|
||||
|
||||
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
||||
const justPastCentre = LGraphCanvas.#lTempC
|
||||
findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51)
|
||||
|
||||
linkSegment._centreAngle = Math.atan2(
|
||||
justPastCentre[1] - pos[1],
|
||||
justPastCentre[0] - pos[0]
|
||||
)
|
||||
}
|
||||
} else {
|
||||
const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10
|
||||
switch (startDir) {
|
||||
case LinkDirection.LEFT:
|
||||
innerA[0] += -l
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
innerA[0] += l
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
innerA[1] += -l
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
innerA[1] += l
|
||||
break
|
||||
}
|
||||
switch (endDir) {
|
||||
case LinkDirection.LEFT:
|
||||
innerB[0] += -l
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
innerB[0] += l
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
innerB[1] += -l
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
innerB[1] += l
|
||||
break
|
||||
}
|
||||
if (this.links_render_mode == LinkRenderType.LINEAR_LINK) {
|
||||
path.moveTo(a[0], a[1] + offsety)
|
||||
path.lineTo(innerA[0], innerA[1] + offsety)
|
||||
path.lineTo(innerB[0], innerB[1] + offsety)
|
||||
path.lineTo(b[0], b[1] + offsety)
|
||||
|
||||
// Calculate centre point
|
||||
pos[0] = (innerA[0] + innerB[0]) * 0.5
|
||||
pos[1] = (innerA[1] + innerB[1]) * 0.5
|
||||
|
||||
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
||||
linkSegment._centreAngle = Math.atan2(
|
||||
innerB[1] - innerA[1],
|
||||
innerB[0] - innerA[0]
|
||||
)
|
||||
}
|
||||
} else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) {
|
||||
const midX = (innerA[0] + innerB[0]) * 0.5
|
||||
|
||||
path.moveTo(a[0], a[1])
|
||||
path.lineTo(innerA[0], innerA[1])
|
||||
path.lineTo(midX, innerA[1])
|
||||
path.lineTo(midX, innerB[1])
|
||||
path.lineTo(innerB[0], innerB[1])
|
||||
path.lineTo(b[0], b[1])
|
||||
|
||||
// Calculate centre point
|
||||
pos[0] = midX
|
||||
pos[1] = (innerA[1] + innerB[1]) * 0.5
|
||||
|
||||
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
||||
const diff = innerB[1] - innerA[1]
|
||||
if (Math.abs(diff) < 4) linkSegment._centreAngle = 0
|
||||
else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5
|
||||
else linkSegment._centreAngle = -(Math.PI * 0.5)
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// rendering the outline of the connection can be a little bit slow
|
||||
if (this.render_connections_border && !this.low_quality && !skip_border) {
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
|
||||
ctx.stroke(path)
|
||||
}
|
||||
|
||||
ctx.lineWidth = this.connections_width
|
||||
ctx.fillStyle = ctx.strokeStyle = linkColour
|
||||
ctx.stroke(path)
|
||||
|
||||
// render arrow in the middle
|
||||
if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) {
|
||||
// render arrow
|
||||
if (this.render_connection_arrows) {
|
||||
// compute two points in the connection
|
||||
const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir)
|
||||
const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir)
|
||||
const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir)
|
||||
const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir)
|
||||
|
||||
// compute the angle between them so the arrow points in the right direction
|
||||
let angleA = 0
|
||||
let angleB = 0
|
||||
if (this.render_curved_connections) {
|
||||
angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1])
|
||||
angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1])
|
||||
} else {
|
||||
angleB = angleA = b[1] > a[1] ? 0 : Math.PI
|
||||
}
|
||||
|
||||
// render arrow
|
||||
const transform = ctx.getTransform()
|
||||
ctx.translate(posA[0], posA[1])
|
||||
ctx.rotate(angleA)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-5, -3)
|
||||
ctx.lineTo(0, +7)
|
||||
ctx.lineTo(+5, -3)
|
||||
ctx.fill()
|
||||
ctx.setTransform(transform)
|
||||
|
||||
ctx.translate(posC[0], posC[1])
|
||||
ctx.rotate(angleB)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(-5, -3)
|
||||
ctx.lineTo(0, +7)
|
||||
ctx.lineTo(+5, -3)
|
||||
ctx.fill()
|
||||
ctx.setTransform(transform)
|
||||
}
|
||||
|
||||
// Draw link centre marker
|
||||
ctx.beginPath()
|
||||
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
|
||||
const transform = ctx.getTransform()
|
||||
ctx.translate(pos[0], pos[1])
|
||||
if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle)
|
||||
// The math is off, but it currently looks better in chromium
|
||||
ctx.moveTo(-3.2, -5)
|
||||
ctx.lineTo(+7, 0)
|
||||
ctx.lineTo(-3.2, +5)
|
||||
ctx.setTransform(transform)
|
||||
} else if (
|
||||
this.linkMarkerShape == null ||
|
||||
this.linkMarkerShape === LinkMarkerShape.Circle
|
||||
) {
|
||||
ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2)
|
||||
}
|
||||
if (disabled) {
|
||||
const { fillStyle, globalAlpha } = ctx
|
||||
ctx.fillStyle = this._pattern ?? '#797979'
|
||||
ctx.globalAlpha = 0.75
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = globalAlpha
|
||||
ctx.fillStyle = fillStyle
|
||||
}
|
||||
ctx.fill()
|
||||
|
||||
if (LLink._drawDebug) {
|
||||
const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx
|
||||
ctx.globalAlpha = 1
|
||||
ctx.lineWidth = 4
|
||||
ctx.fillStyle = 'white'
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.font = '16px Arial'
|
||||
|
||||
const text = String(linkSegment.id)
|
||||
const { width, actualBoundingBoxAscent } = ctx.measureText(text)
|
||||
const x = pos[0] - width * 0.5
|
||||
const y = pos[1] + actualBoundingBoxAscent * 0.5
|
||||
ctx.strokeText(text, x, y)
|
||||
ctx.fillText(text, x, y)
|
||||
|
||||
ctx.font = font
|
||||
ctx.globalAlpha = globalAlpha
|
||||
ctx.lineWidth = lineWidth
|
||||
ctx.fillStyle = fillStyle
|
||||
ctx.strokeStyle = strokeStyle
|
||||
}
|
||||
}
|
||||
|
||||
// render flowing points
|
||||
if (flow) {
|
||||
ctx.fillStyle = linkColour
|
||||
for (let i = 0; i < 5; ++i) {
|
||||
const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1
|
||||
const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir)
|
||||
ctx.beginPath()
|
||||
ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir.
|
||||
* @param a Start point
|
||||
* @param b End point
|
||||
* @param t Time: distance between points (e.g 0.25 is 25% along the line)
|
||||
* @param start_dir Spline start direction
|
||||
* @param end_dir Spline end direction
|
||||
* @returns The point at {@link t} distance along the spline a-b.
|
||||
*/
|
||||
computeConnectionPoint(
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
t: number,
|
||||
start_dir: LinkDirection,
|
||||
end_dir: LinkDirection
|
||||
): Point {
|
||||
start_dir ||= LinkDirection.RIGHT
|
||||
end_dir ||= LinkDirection.LEFT
|
||||
|
||||
const dist = distance(a, b)
|
||||
const pa: Point = [a[0], a[1]]
|
||||
const pb: Point = [b[0], b[1]]
|
||||
|
||||
this.#addSplineOffset(pa, start_dir, dist)
|
||||
this.#addSplineOffset(pb, end_dir, dist)
|
||||
|
||||
const c1 = (1 - t) * (1 - t) * (1 - t)
|
||||
const c2 = 3 * ((1 - t) * (1 - t)) * t
|
||||
const c3 = 3 * (1 - t) * (t * t)
|
||||
const c4 = t * t * t
|
||||
|
||||
const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0]
|
||||
const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1]
|
||||
return [x, y]
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies an existing point, adding a single-axis offset.
|
||||
* @param point The point to add the offset to
|
||||
* @param direction The direction to add the offset in
|
||||
* @param dist Distance to offset
|
||||
* @param factor Distance is mulitplied by this value. Default: 0.25
|
||||
*/
|
||||
#addSplineOffset(
|
||||
point: Point,
|
||||
direction: LinkDirection,
|
||||
dist: number,
|
||||
factor = 0.25
|
||||
): void {
|
||||
switch (direction) {
|
||||
case LinkDirection.LEFT:
|
||||
point[0] += dist * -factor
|
||||
break
|
||||
case LinkDirection.RIGHT:
|
||||
point[0] += dist * factor
|
||||
break
|
||||
case LinkDirection.UP:
|
||||
point[1] += dist * -factor
|
||||
break
|
||||
case LinkDirection.DOWN:
|
||||
point[1] += dist * factor
|
||||
break
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6336,6 +6161,8 @@ export class LGraphCanvas
|
||||
: segment.id
|
||||
if (linkId !== undefined) {
|
||||
graph.removeLink(linkId)
|
||||
// Clean up layout store
|
||||
layoutStore.deleteLinkLayout(linkId)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -8413,11 +8240,22 @@ export class LGraphCanvas
|
||||
|
||||
// Check for reroutes
|
||||
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
|
||||
const reroute = this.graph.getRerouteOnPos(
|
||||
event.canvasX,
|
||||
event.canvasY,
|
||||
this.#visibleReroutes
|
||||
)
|
||||
// Try layout store first, fallback to old method
|
||||
const rerouteLayout = layoutStore.queryRerouteAtPoint({
|
||||
x: event.canvasX,
|
||||
y: event.canvasY
|
||||
})
|
||||
|
||||
let reroute: Reroute | undefined
|
||||
if (rerouteLayout) {
|
||||
reroute = this.graph.getReroute(rerouteLayout.id)
|
||||
} else {
|
||||
reroute = this.graph.getRerouteOnPos(
|
||||
event.canvasX,
|
||||
event.canvasY,
|
||||
this.#visibleReroutes
|
||||
)
|
||||
}
|
||||
if (reroute) {
|
||||
menu_info.unshift(
|
||||
{
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
|
||||
import {
|
||||
type SlotPositionContext,
|
||||
calculateInputSlotPos,
|
||||
calculateInputSlotPosFromSlot,
|
||||
calculateOutputSlotPos
|
||||
} from '@/renderer/core/canvas/litegraph/slotCalculations'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
import type { DragAndScale } from './DragAndScale'
|
||||
import type { LGraph } from './LGraph'
|
||||
import { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
@@ -258,6 +268,10 @@ export class LGraphNode
|
||||
properties_info: INodePropertyInfo[] = []
|
||||
flags: INodeFlags = {}
|
||||
widgets?: IBaseWidget[]
|
||||
|
||||
/** Property manager for this node */
|
||||
changeTracker: LGraphNodeProperties
|
||||
|
||||
/**
|
||||
* The amount of space available for widgets to grow into.
|
||||
* @see {@link layoutWidgets}
|
||||
@@ -729,6 +743,37 @@ export class LGraphNode
|
||||
error: this.#getErrorStrokeStyle,
|
||||
selected: this.#getSelectedStrokeStyle
|
||||
}
|
||||
|
||||
// Assign onMouseDown implementation
|
||||
this.onMouseDown = (
|
||||
// @ts-expect-error - CanvasPointerEvent type needs fixing
|
||||
e: CanvasPointerEvent,
|
||||
pos: Point,
|
||||
canvas: LGraphCanvas
|
||||
): boolean => {
|
||||
// Check for title button clicks (only if not collapsed)
|
||||
if (this.title_buttons?.length && !this.flags.collapsed) {
|
||||
// pos contains the offset from the node's position, so we need to use node-relative coordinates
|
||||
const nodeRelativeX = pos[0]
|
||||
const nodeRelativeY = pos[1]
|
||||
|
||||
for (let i = 0; i < this.title_buttons.length; i++) {
|
||||
const button = this.title_buttons[i]
|
||||
if (
|
||||
button.visible &&
|
||||
button.isPointInside(nodeRelativeX, nodeRelativeY)
|
||||
) {
|
||||
this.onTitleButtonClick(button, canvas)
|
||||
return true // Prevent default behavior
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false // Allow default behavior
|
||||
}
|
||||
|
||||
// Initialize property manager with tracked properties
|
||||
this.changeTracker = new LGraphNodeProperties(this)
|
||||
}
|
||||
|
||||
/** Internal callback for subgraph nodes. Do not implement externally. */
|
||||
@@ -1941,6 +1986,14 @@ export class LGraphNode
|
||||
move(deltaX: number, deltaY: number): void {
|
||||
if (this.pinned) return
|
||||
|
||||
// If Vue nodes mode is enabled, skip LiteGraph's direct position update
|
||||
// The layout store will handle the movement and sync back to LiteGraph
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Vue nodes handle their own dragging through the layout store
|
||||
// This prevents the snap-back issue from conflicting position updates
|
||||
return
|
||||
}
|
||||
|
||||
this.pos[0] += deltaX
|
||||
this.pos[1] += deltaY
|
||||
}
|
||||
@@ -2745,6 +2798,8 @@ export class LGraphNode
|
||||
const { graph } = this
|
||||
if (!graph) throw new NullGraphError()
|
||||
|
||||
const layoutMutations = useLayoutMutations()
|
||||
|
||||
const outputIndex = this.outputs.indexOf(output)
|
||||
if (outputIndex === -1) {
|
||||
console.warn('connectSlots: output not found')
|
||||
@@ -2803,6 +2858,16 @@ export class LGraphNode
|
||||
// add to graph links list
|
||||
graph._links.set(link.id, link)
|
||||
|
||||
// Register link in Layout Store for spatial tracking
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.createLink(
|
||||
link.id,
|
||||
this.id,
|
||||
outputIndex,
|
||||
inputNode.id,
|
||||
inputIndex
|
||||
)
|
||||
|
||||
// connect in output
|
||||
output.links ??= []
|
||||
output.links.push(link.id)
|
||||
@@ -3204,6 +3269,25 @@ export class LGraphNode
|
||||
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the context needed for slot position calculations
|
||||
* @internal
|
||||
*/
|
||||
#getSlotPositionContext(): SlotPositionContext {
|
||||
return {
|
||||
nodeX: this.pos[0],
|
||||
nodeY: this.pos[1],
|
||||
nodeWidth: this.size[0],
|
||||
nodeHeight: this.size[1],
|
||||
collapsed: this.flags.collapsed ?? false,
|
||||
collapsedWidth: this._collapsed_width,
|
||||
slotStartY: this.constructor.slot_start_y,
|
||||
inputs: this.inputs,
|
||||
outputs: this.outputs,
|
||||
widgets: this.widgets
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the position of an input slot, in graph co-ordinates.
|
||||
*
|
||||
@@ -3212,7 +3296,7 @@ export class LGraphNode
|
||||
* @returns Position of the input slot
|
||||
*/
|
||||
getInputPos(slot: number): Point {
|
||||
return this.getInputSlotPos(this.inputs[slot])
|
||||
return calculateInputSlotPos(this.#getSlotPositionContext(), slot)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3221,25 +3305,7 @@ export class LGraphNode
|
||||
* @returns Position of the centre of the input slot in graph co-ordinates.
|
||||
*/
|
||||
getInputSlotPos(input: INodeInputSlot): Point {
|
||||
const {
|
||||
pos: [nodeX, nodeY]
|
||||
} = this
|
||||
|
||||
if (this.flags.collapsed) {
|
||||
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
|
||||
return [nodeX, nodeY - halfTitle]
|
||||
}
|
||||
|
||||
const { pos } = input
|
||||
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
|
||||
|
||||
// default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = this.constructor.slot_start_y || 0
|
||||
const slotIndex = this.#defaultVerticalInputs.indexOf(input)
|
||||
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
|
||||
|
||||
return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
|
||||
return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3250,29 +3316,7 @@ export class LGraphNode
|
||||
* @returns Position of the output slot
|
||||
*/
|
||||
getOutputPos(slot: number): Point {
|
||||
const {
|
||||
pos: [nodeX, nodeY],
|
||||
outputs,
|
||||
size: [width]
|
||||
} = this
|
||||
|
||||
if (this.flags.collapsed) {
|
||||
const width = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
|
||||
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
|
||||
return [nodeX + width, nodeY - halfTitle]
|
||||
}
|
||||
|
||||
const outputPos = outputs?.[slot]?.pos
|
||||
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
|
||||
|
||||
// default vertical slots
|
||||
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
const nodeOffsetY = this.constructor.slot_start_y || 0
|
||||
const slotIndex = this.#defaultVerticalOutputs.indexOf(this.outputs[slot])
|
||||
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
|
||||
|
||||
// TODO: Why +1?
|
||||
return [nodeX + width + 1 - offsetX, nodeY + slotY + nodeOffsetY]
|
||||
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
@@ -3818,12 +3862,33 @@ export class LGraphNode
|
||||
? this.getInputPos(slotIndex)
|
||||
: this.getOutputPos(slotIndex)
|
||||
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// Vue-based slot dimensions
|
||||
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
|
||||
|
||||
if (slot.isWidgetInputSlot) {
|
||||
// Widget slots have a 20x20 clickable area centered at the position
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - 10
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = 20
|
||||
} else {
|
||||
// Regular slots have a 20x20 clickable area for the connector
|
||||
// but the full slot height for vertical spacing
|
||||
slot.boundingRect[0] = pos[0] - 10
|
||||
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
|
||||
slot.boundingRect[2] = 20
|
||||
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
|
||||
}
|
||||
} else {
|
||||
// Traditional LiteGraph dimensions
|
||||
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
slot.boundingRect[2] = slot.isWidgetInputSlot
|
||||
? BaseWidget.margin
|
||||
: LiteGraph.NODE_SLOT_HEIGHT
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
}
|
||||
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
@@ -4019,14 +4084,26 @@ export class LGraphNode
|
||||
}
|
||||
if (!slotByWidgetName.size) return
|
||||
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
// Only set custom pos if not using Vue positioning
|
||||
// Vue positioning calculates widget slot positions dynamically
|
||||
if (!LiteGraph.vueNodesMode) {
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
const actualSlot = this.#concreteInputs[slot.index]
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
actualSlot.pos = [offset, widget.y + offset]
|
||||
this.#measureSlot(actualSlot, slot.index, true)
|
||||
const actualSlot = this.#concreteInputs[slot.index]
|
||||
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
|
||||
actualSlot.pos = [offset, widget.y + offset]
|
||||
this.#measureSlot(actualSlot, slot.index, true)
|
||||
}
|
||||
} else {
|
||||
// For Vue positioning, just measure the slots without setting pos
|
||||
for (const widget of this.widgets) {
|
||||
const slot = slotByWidgetName.get(widget.name)
|
||||
if (!slot) continue
|
||||
|
||||
this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
176
src/lib/litegraph/src/LGraphNodeProperties.ts
Normal file
176
src/lib/litegraph/src/LGraphNodeProperties.ts
Normal file
@@ -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<string>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<SerialisableLLink> {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -73,7 +73,7 @@ export abstract class NodeSlot extends SlotBase implements INodeSlot {
|
||||
slot: OptionalProps<INodeSlot, 'boundingRect'>,
|
||||
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,
|
||||
|
||||
@@ -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<boolean, 'toggle'> {
|
||||
type: 'toggle'
|
||||
@@ -138,6 +149,81 @@ export interface ICustomWidget extends IBaseWidget<string | object, 'custom'> {
|
||||
value: string | object
|
||||
}
|
||||
|
||||
/** File upload widget for selecting and uploading files */
|
||||
export interface IFileUploadWidget extends IBaseWidget<string, 'fileupload'> {
|
||||
type: 'fileupload'
|
||||
value: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** Color picker widget for selecting colors */
|
||||
export interface IColorWidget extends IBaseWidget<string, 'color'> {
|
||||
type: 'color'
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Markdown widget for displaying formatted text */
|
||||
export interface IMarkdownWidget extends IBaseWidget<string, 'markdown'> {
|
||||
type: 'markdown'
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Image display widget */
|
||||
export interface IImageWidget extends IBaseWidget<string, 'image'> {
|
||||
type: 'image'
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Tree select widget for hierarchical selection */
|
||||
export interface ITreeSelectWidget
|
||||
extends IBaseWidget<string | string[], 'treeselect'> {
|
||||
type: 'treeselect'
|
||||
value: string | string[]
|
||||
}
|
||||
|
||||
/** Multi-select widget for selecting multiple options */
|
||||
export interface IMultiSelectWidget
|
||||
extends IBaseWidget<string[], 'multiselect'> {
|
||||
type: 'multiselect'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
/** Chart widget for displaying data visualizations */
|
||||
export interface IChartWidget extends IBaseWidget<object, 'chart'> {
|
||||
type: 'chart'
|
||||
value: object
|
||||
}
|
||||
|
||||
/** Gallery widget for displaying multiple images */
|
||||
export interface IGalleriaWidget extends IBaseWidget<string[], 'galleria'> {
|
||||
type: 'galleria'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
/** Image comparison widget for comparing two images side by side */
|
||||
export interface IImageCompareWidget
|
||||
extends IBaseWidget<string[], 'imagecompare'> {
|
||||
type: 'imagecompare'
|
||||
value: string[]
|
||||
}
|
||||
|
||||
/** Select button widget for selecting from a group of buttons */
|
||||
export interface ISelectButtonWidget
|
||||
extends IBaseWidget<
|
||||
string,
|
||||
'selectbutton',
|
||||
RequiredProps<IWidgetOptions<string[]>, 'values'>
|
||||
> {
|
||||
type: 'selectbutton'
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Textarea widget for multi-line text input */
|
||||
export interface ITextareaWidget extends IBaseWidget<string, 'textarea'> {
|
||||
type: 'textarea'
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid widget types. TS cannot provide easily extensible type safety for this at present.
|
||||
* Override linkedWidgets[]
|
||||
|
||||
50
src/lib/litegraph/src/widgets/ChartWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/ChartWidget.ts
Normal file
@@ -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<IChartWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/ColorWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/ColorWidget.ts
Normal file
@@ -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<IColorWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/FileUploadWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/FileUploadWidget.ts
Normal file
@@ -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<IFileUploadWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/GalleriaWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/GalleriaWidget.ts
Normal file
@@ -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<IGalleriaWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/ImageCompareWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/ImageCompareWidget.ts
Normal file
@@ -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<IImageCompareWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/MarkdownWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/MarkdownWidget.ts
Normal file
@@ -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<IMarkdownWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/MultiSelectWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/MultiSelectWidget.ts
Normal file
@@ -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<IMultiSelectWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/SelectButtonWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/SelectButtonWidget.ts
Normal file
@@ -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<ISelectButtonWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/TextareaWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/TextareaWidget.ts
Normal file
@@ -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<ITextareaWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
50
src/lib/litegraph/src/widgets/TreeSelectWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/TreeSelectWidget.ts
Normal file
@@ -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<ITreeSelectWidget>
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<TWidget extends IWidget | IBaseWidget>(
|
||||
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)
|
||||
}
|
||||
|
||||
163
src/lib/litegraph/test/LGraphNodeProperties.test.ts
Normal file
163
src/lib/litegraph/test/LGraphNodeProperties.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "ワークフローを保存"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"label": "필수 하단 패널 전환"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "보기 컨트롤 하단 패널 전환"
|
||||
"label": "뷰 컨트롤 하단 패널 전환"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "키 바인딩 대화상자 표시"
|
||||
|
||||
@@ -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": "워크플로 저장"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -282,7 +282,7 @@
|
||||
"label": "Показать/скрыть основную нижнюю панель"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "Показать или скрыть нижнюю панель управления просмотром"
|
||||
"label": "Показать/скрыть нижнюю панель управления просмотром"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "Показать диалог клавиш"
|
||||
|
||||
@@ -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": "Сохранить рабочий процесс"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "儲存工作流程"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "切换焦点模式"
|
||||
|
||||
@@ -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": "适合画面"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
589
src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
Normal file
589
src/renderer/core/canvas/litegraph/litegraphLinkAdapter.ts
Normal file
@@ -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<string | number>
|
||||
|
||||
// Colors
|
||||
defaultLinkColor: CanvasColour
|
||||
linkTypeColors: Record<string, CanvasColour>
|
||||
|
||||
// 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<string, CanvasColour>
|
||||
): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
283
src/renderer/core/canvas/litegraph/slotCalculations.ts
Normal file
283
src/renderer/core/canvas/litegraph/slotCalculations.ts
Normal file
@@ -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]
|
||||
}
|
||||
820
src/renderer/core/canvas/pathRenderer.ts
Normal file
820
src/renderer/core/canvas/pathRenderer.ts
Normal file
@@ -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<string, string>
|
||||
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<string>
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/renderer/core/layout/constants.ts
Normal file
50
src/renderer/core/layout/constants.ts
Normal file
@@ -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
|
||||
340
src/renderer/core/layout/operations/layoutMutations.ts
Normal file
340
src/renderer/core/layout/operations/layoutMutations.ts
Normal file
@@ -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<NodeLayout>): 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<NodeLayout>): 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
|
||||
}
|
||||
}
|
||||
75
src/renderer/core/layout/slots/register.ts
Normal file
75
src/renderer/core/layout/slots/register.ts
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
40
src/renderer/core/layout/slots/slotIdentifier.ts
Normal file
40
src/renderer/core/layout/slots/slotIdentifier.ts
Normal file
@@ -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}`
|
||||
}
|
||||
229
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
229
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
@@ -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<object>()
|
||||
|
||||
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<HTMLElement | null>,
|
||||
{
|
||||
stopWatcher?: WatchStopHandle
|
||||
handleResize?: () => void
|
||||
}
|
||||
>()
|
||||
|
||||
interface SlotRegistrationOptions {
|
||||
nodeId: string
|
||||
slotIndex: number
|
||||
isInput: boolean
|
||||
element: Ref<HTMLElement | null>
|
||||
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<LayoutPoint | null>(null)
|
||||
const lastMeasuredBounds = ref<DOMRect | null>(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)
|
||||
}
|
||||
}
|
||||
1377
src/renderer/core/layout/store/layoutStore.ts
Normal file
1377
src/renderer/core/layout/store/layoutStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
79
src/renderer/core/layout/sync/useLayoutSync.ts
Normal file
79
src/renderer/core/layout/sync/useLayoutSync.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
365
src/renderer/core/layout/sync/useLinkLayoutSync.ts
Normal file
365
src/renderer/core/layout/sync/useLinkLayoutSync.ts
Normal file
@@ -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<number>()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
166
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
166
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export 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
|
||||
* @returns true if sync was actually started, false if early-returned
|
||||
*/
|
||||
function start(canvas: LGraphCanvas): boolean {
|
||||
// When Vue nodes are enabled, slot DOM registers exact positions.
|
||||
// Skip calculated registration to avoid conflicts.
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
return false
|
||||
}
|
||||
const graph = canvas?.graph
|
||||
if (!graph) return false
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
323
src/renderer/core/layout/types.ts
Normal file
323
src/renderer/core/layout/types.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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<NodeLayout>
|
||||
previousValues: Partial<NodeLayout>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<NodeLayout | null>
|
||||
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
|
||||
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
|
||||
getVersion(): ComputedRef<number>
|
||||
|
||||
// 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<LinkSegmentLayout, 'linkId' | 'rerouteId'>
|
||||
): 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
|
||||
clearAllSlotLayouts(): 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
|
||||
}
|
||||
169
src/renderer/core/spatial/SpatialIndex.ts
Normal file
169
src/renderer/core/spatial/SpatialIndex.ts
Normal file
@@ -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<NodeId>
|
||||
private queryCache: Map<string, CacheEntry>
|
||||
private cacheSize = 0
|
||||
|
||||
constructor(bounds?: Bounds) {
|
||||
this.quadTree = new QuadTree<NodeId>(
|
||||
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
|
||||
}
|
||||
}
|
||||
107
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
107
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible,
|
||||
'lg-slot--dot-only': dotOnly,
|
||||
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
|
||||
}"
|
||||
:style="{
|
||||
height: slotHeight + 'px'
|
||||
}"
|
||||
>
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
class="-translate-x-1/2"
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import {
|
||||
COMFY_VUE_NODE_DIMENSIONS,
|
||||
INodeSlot,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
// DOM-based slot registration for arbitrary positioning
|
||||
import {
|
||||
type TransformState,
|
||||
useDomSlotRegistration
|
||||
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
|
||||
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
node?: LGraphNode
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
dotOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Get slot height from litegraph constants
|
||||
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
|
||||
|
||||
const transformState = inject<TransformState | undefined>(
|
||||
'transformState',
|
||||
undefined
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<{ slotElRef: Ref<HTMLElement> }>()
|
||||
const slotElRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Watch for connection dot ref changes and sync the element ref
|
||||
watch(
|
||||
connectionDotRef,
|
||||
(newValue) => {
|
||||
if (newValue?.slotElRef) {
|
||||
slotElRef.value = newValue.slotElRef.value
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useDomSlotRegistration({
|
||||
nodeId: props.nodeId ?? '',
|
||||
slotIndex: props.index,
|
||||
isInput: true,
|
||||
element: slotElRef,
|
||||
transform: transformState
|
||||
})
|
||||
</script>
|
||||
271
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
271
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Render Error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-white dark-theme:bg-[#15161A]',
|
||||
'min-w-[445px]',
|
||||
'lg-node absolute border border-solid rounded-2xl',
|
||||
'outline outline-transparent outline-2 hover:outline-black dark-theme:hover:outline-white',
|
||||
{
|
||||
'border-blue-500 ring-2 ring-blue-300': selected,
|
||||
'border-[#e1ded5] dark-theme:border-[#292A30]': !selected,
|
||||
'animate-pulse': executing,
|
||||
'opacity-50': nodeData.mode === 4,
|
||||
'border-red-500 bg-red-50': error,
|
||||
'will-change-transform': isDragging
|
||||
},
|
||||
lodCssClass
|
||||
)
|
||||
"
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
pointerEvents: 'auto'
|
||||
},
|
||||
dragStyle
|
||||
]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
||||
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
|
||||
</template>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[nodeData.title, lodLevel, isCollapsed]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="!isMinimalLOD && !isCollapsed">
|
||||
<div :class="cn(separatorClasses, 'mb-4')" />
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots
|
||||
v-if="shouldRenderSlots"
|
||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
@slot-click="handleSlotClick"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="shouldRenderSlots && shouldShowWidgets"
|
||||
:class="separatorClasses"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="shouldShowWidgets"
|
||||
v-memo="[nodeData.widgets?.length, lodLevel]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="(shouldRenderSlots || shouldShowWidgets) && shouldShowContent"
|
||||
:class="separatorClasses"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="shouldShowContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
|
||||
:style="{ width: `${progress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
|
||||
|
||||
// Import the VueNodeData type
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
|
||||
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
// Extended props for main node component
|
||||
interface LGraphNodeProps {
|
||||
nodeData: VueNodeData
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
readonly?: boolean
|
||||
selected?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
zoomLevel?: number
|
||||
}
|
||||
|
||||
const props = defineProps<LGraphNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'node-click': [event: PointerEvent, nodeData: VueNodeData]
|
||||
'slot-click': [
|
||||
event: PointerEvent,
|
||||
nodeData: VueNodeData,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
]
|
||||
'update:collapsed': [nodeId: string, collapsed: boolean]
|
||||
'update:title': [nodeId: string, newTitle: string]
|
||||
}>()
|
||||
|
||||
// LOD (Level of Detail) system based on zoom level
|
||||
const zoomRef = toRef(() => props.zoomLevel ?? 1)
|
||||
const {
|
||||
lodLevel,
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
lodCssClass
|
||||
} = useLOD(zoomRef)
|
||||
|
||||
// Computed properties for template usage
|
||||
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const {
|
||||
position: layoutPosition,
|
||||
startDrag,
|
||||
handleDrag: handleLayoutDrag,
|
||||
endDrag
|
||||
} = useNodeLayout(props.nodeData.id)
|
||||
|
||||
// Drag state for styling
|
||||
const isDragging = ref(false)
|
||||
const dragStyle = computed(() => ({
|
||||
cursor: isDragging.value ? 'grabbing' : 'grab'
|
||||
}))
|
||||
|
||||
// Track collapsed state
|
||||
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
|
||||
|
||||
// Watch for external changes to the collapsed state
|
||||
watch(
|
||||
() => props.nodeData.flags?.collapsed,
|
||||
(newCollapsed) => {
|
||||
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
|
||||
isCollapsed.value = newCollapsed
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Check if node has custom content
|
||||
const hasCustomContent = computed(() => {
|
||||
// Currently all content is handled through widgets
|
||||
// This remains false but provides extensibility point
|
||||
return false
|
||||
})
|
||||
|
||||
// Computed classes and conditions for better reusability
|
||||
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
|
||||
|
||||
// Common condition computations to avoid repetition
|
||||
const shouldShowWidgets = computed(
|
||||
() => shouldRenderWidgets.value && props.nodeData.widgets?.length
|
||||
)
|
||||
|
||||
const shouldShowContent = computed(
|
||||
() => shouldRenderContent.value && hasCustomContent.value
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!props.nodeData) {
|
||||
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
|
||||
return
|
||||
}
|
||||
|
||||
// Start drag using layout system
|
||||
isDragging.value = true
|
||||
startDrag(event)
|
||||
|
||||
// Emit node-click for selection handling in GraphCanvas
|
||||
emit('node-click', event, props.nodeData)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
void handleLayoutDrag(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePointerUp = (event: PointerEvent) => {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false
|
||||
void endDrag(event)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
// Emit event so parent can sync with LiteGraph if needed
|
||||
emit('update:collapsed', props.nodeData.id, isCollapsed.value)
|
||||
}
|
||||
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
if (!props.nodeData) {
|
||||
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
|
||||
return
|
||||
}
|
||||
emit('slot-click', event, props.nodeData, slotIndex, isInput)
|
||||
}
|
||||
|
||||
const handleTitleUpdate = (newTitle: string) => {
|
||||
emit('update:title', props.nodeData.id, newTitle)
|
||||
}
|
||||
</script>
|
||||
40
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
40
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Content Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<!-- This component serves as a placeholder for future extensibility -->
|
||||
<!-- Currently all node content is rendered through the widget system -->
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
defineProps<NodeContentProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
115
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
115
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-4 text-red-500 text-sm">
|
||||
{{ $t('Node Header Error') }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
|
||||
:data-testid="`node-header-${nodeInfo?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-show="!readonly"
|
||||
class="bg-transparent border-transparent flex items-center"
|
||||
data-testid="node-collapse-button"
|
||||
@click.stop="handleCollapse"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-[1px] text-[#888682] dark-theme:text-[#5B5E7D]"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div class="text-sm font-bold truncate flex-1" data-testid="node-title">
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'update:title': [newTitle: string]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Editing state
|
||||
const isEditing = ref(false)
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node)
|
||||
|
||||
// Local state for title to provide immediate feedback
|
||||
const displayTitle = ref(nodeInfo.value?.title || 'Untitled')
|
||||
|
||||
// Watch for external changes to the node title
|
||||
watch(
|
||||
() => nodeInfo.value?.title,
|
||||
(newTitle) => {
|
||||
if (newTitle && newTitle !== displayTitle.value) {
|
||||
displayTitle.value = newTitle
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!props.readonly) {
|
||||
isEditing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleEdit = (newTitle: string) => {
|
||||
isEditing.value = false
|
||||
const trimmedTitle = newTitle.trim()
|
||||
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
|
||||
// Emit for litegraph sync
|
||||
emit('update:title', trimmedTitle)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
isEditing.value = false
|
||||
// Reset displayTitle to the current node title
|
||||
displayTitle.value = nodeInfo.value?.title || 'Untitled'
|
||||
}
|
||||
</script>
|
||||
113
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
113
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Slots Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-slots flex justify-between">
|
||||
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
|
||||
<InputSlot
|
||||
v-for="(input, index) in filteredInputs"
|
||||
:key="`input-${index}`"
|
||||
:slot-data="input"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:index="getActualInputIndex(input, index)"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredOutputs.length" class="flex flex-col gap-1 ml-auto">
|
||||
<OutputSlot
|
||||
v-for="(output, index) in filteredOutputs"
|
||||
:key="`output-${index}`"
|
||||
:slot-data="output"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:index="index"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { isSlotObject } from '@/utils/typeGuardUtil'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
interface NodeSlotsProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const props = defineProps<NodeSlotsProps>()
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node || null)
|
||||
|
||||
// Filter out input slots that have corresponding widgets
|
||||
const filteredInputs = computed(() => {
|
||||
if (!nodeInfo.value?.inputs) return []
|
||||
|
||||
return nodeInfo.value.inputs
|
||||
.filter((input) => {
|
||||
// Check if this slot has a widget property (indicating it has a corresponding widget)
|
||||
if (isSlotObject(input) && 'widget' in input && input.widget) {
|
||||
// This slot has a widget, so we should not display it separately
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map((input) =>
|
||||
isSlotObject(input)
|
||||
? input
|
||||
: ({
|
||||
name: typeof input === 'string' ? input : '',
|
||||
type: 'any',
|
||||
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
|
||||
} as INodeSlot)
|
||||
)
|
||||
})
|
||||
|
||||
// Outputs don't have widgets, so we don't need to filter them
|
||||
const filteredOutputs = computed(() => {
|
||||
const outputs = nodeInfo.value?.outputs || []
|
||||
return outputs.map((output) =>
|
||||
isSlotObject(output)
|
||||
? output
|
||||
: ({
|
||||
name: typeof output === 'string' ? output : '',
|
||||
type: 'any',
|
||||
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
|
||||
} as INodeSlot)
|
||||
)
|
||||
})
|
||||
|
||||
// Get the actual index of an input slot in the node's inputs array
|
||||
// (accounting for filtered widget slots)
|
||||
const getActualInputIndex = (
|
||||
input: INodeSlot,
|
||||
filteredIndex: number
|
||||
): number => {
|
||||
if (!nodeInfo.value?.inputs) return filteredIndex
|
||||
|
||||
// Find the actual index in the unfiltered inputs array
|
||||
const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input)
|
||||
return actualIndex !== -1 ? actualIndex : filteredIndex
|
||||
}
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
155
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal file
155
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Widgets Error') }}
|
||||
</div>
|
||||
<div v-else class="lg-node-widgets flex flex-col gap-2 pr-4">
|
||||
<div
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="lg-widget-container relative flex items-center group"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
<InputSlot
|
||||
:slot-data="{
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}"
|
||||
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
|
||||
:index="getWidgetInputIndex(widget)"
|
||||
:readonly="readonly"
|
||||
:dot-only="true"
|
||||
/>
|
||||
</div>
|
||||
<!-- Widget Component -->
|
||||
<component
|
||||
:is="widget.vueComponent"
|
||||
:widget="widget.simplified"
|
||||
:model-value="widget.value"
|
||||
:readonly="readonly"
|
||||
class="flex-1"
|
||||
@update:model-value="widget.updateHandler"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
SafeWidgetData,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
// Import widget components directly
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
import {
|
||||
getComponent,
|
||||
isEssential,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
node?: LGraphNode
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const props = defineProps<NodeWidgetsProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
const nodeInfo = computed(() => props.nodeData || props.node)
|
||||
|
||||
interface ProcessedWidget {
|
||||
name: string
|
||||
type: string
|
||||
vueComponent: any
|
||||
simplified: SimplifiedWidget
|
||||
value: WidgetValue
|
||||
updateHandler: (value: unknown) => void
|
||||
}
|
||||
|
||||
const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
const info = nodeInfo.value
|
||||
if (!info?.widgets) return []
|
||||
|
||||
const widgets = info.widgets as SafeWidgetData[]
|
||||
const lodLevel = props.lodLevel
|
||||
const result: ProcessedWidget[] = []
|
||||
|
||||
if (lodLevel === LODLevel.MINIMAL) {
|
||||
return []
|
||||
}
|
||||
|
||||
for (const widget of widgets) {
|
||||
if (widget.options?.hidden) continue
|
||||
if (widget.options?.canvasOnly) continue
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
options: widget.options,
|
||||
callback: widget.callback
|
||||
}
|
||||
|
||||
const updateHandler = (value: unknown) => {
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
vueComponent,
|
||||
simplified,
|
||||
value: widget.value,
|
||||
updateHandler
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
|
||||
// or restructuring data model to unify widgets and inputs
|
||||
// Map a widget to its corresponding input slot index
|
||||
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
|
||||
const inputs = nodeInfo.value?.inputs
|
||||
if (!inputs) return 0
|
||||
|
||||
const idx = inputs.findIndex((input: any) => {
|
||||
if (!input || typeof input !== 'object') return false
|
||||
if (!('name' in input && 'type' in input)) return false
|
||||
return 'widget' in input && input.widget?.name === widget.name
|
||||
})
|
||||
return idx >= 0 ? idx : 0
|
||||
}
|
||||
</script>
|
||||
106
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
106
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible,
|
||||
'lg-slot--dot-only': dotOnly,
|
||||
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
|
||||
'justify-center': dotOnly
|
||||
}"
|
||||
:style="{
|
||||
height: slotHeight + 'px'
|
||||
}"
|
||||
>
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
>
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
:color="slotColor"
|
||||
class="translate-x-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
|
||||
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
|
||||
// DOM-based slot registration for arbitrary positioning
|
||||
import {
|
||||
type TransformState,
|
||||
useDomSlotRegistration
|
||||
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
|
||||
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
node?: LGraphNode
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
dotOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Get slot height from litegraph constants
|
||||
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
|
||||
|
||||
const transformState = inject<TransformState | undefined>(
|
||||
'transformState',
|
||||
undefined
|
||||
)
|
||||
|
||||
const connectionDotRef = ref<{ slotElRef: Ref<HTMLElement> }>()
|
||||
const slotElRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Watch for connection dot ref changes and sync the element ref
|
||||
watch(
|
||||
connectionDotRef,
|
||||
(newValue) => {
|
||||
if (newValue?.slotElRef) {
|
||||
slotElRef.value = newValue.slotElRef.value
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
useDomSlotRegistration({
|
||||
nodeId: props.nodeId ?? '',
|
||||
slotIndex: props.index,
|
||||
isInput: false,
|
||||
element: slotElRef,
|
||||
transform: transformState
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { type ClassValue, cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
color?: string
|
||||
multi?: boolean
|
||||
class?: ClassValue
|
||||
}>()
|
||||
|
||||
const slotElRef = useTemplateRef('slot-el')
|
||||
|
||||
defineExpose({
|
||||
slotElRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn('size-6 flex items-center justify-center group/slot', props.class)
|
||||
"
|
||||
>
|
||||
<div
|
||||
ref="slot-el"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-[#5B5E7D] rounded-full',
|
||||
'transition-all duration-150',
|
||||
'cursor-crosshair',
|
||||
'border border-solid border-black/5 dark-theme:border-white/10',
|
||||
'group-hover/slot:border-black/20 dark-theme:group-hover/slot:border-white/50 group-hover/slot:scale-125',
|
||||
multi ? 'w-3 h-6' : 'size-3'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
// ... other props
|
||||
}>()
|
||||
|
||||
// Get LOD information
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
</script>
|
||||
```
|
||||
|
||||
**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
|
||||
<template>
|
||||
<div class="number-widget">
|
||||
<!-- The main control always shows -->
|
||||
<input
|
||||
v-model="value"
|
||||
type="range"
|
||||
:min="widget.min"
|
||||
:max="widget.max"
|
||||
class="widget-slider"
|
||||
/>
|
||||
|
||||
<!-- Show label only when zoomed in enough to read it -->
|
||||
<label
|
||||
v-if="showLabel"
|
||||
class="widget-label"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</label>
|
||||
|
||||
<!-- Show precise value only when fully zoomed in -->
|
||||
<span
|
||||
v-if="showValue"
|
||||
class="widget-value"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</span>
|
||||
|
||||
<!-- Show description only at full detail -->
|
||||
<div
|
||||
v-if="showDescription && widget.description"
|
||||
class="widget-description"
|
||||
>
|
||||
{{ widget.description }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
}>()
|
||||
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
|
||||
// Define when to show each element
|
||||
const showLabel = computed(() => {
|
||||
// Show label when user can actually read it
|
||||
return lodScore.value > 0.4 // Roughly 12px+ text size
|
||||
})
|
||||
|
||||
const showValue = computed(() => {
|
||||
// Show precise value only when zoomed in close
|
||||
return lodScore.value > 0.7 // User is focused on this specific widget
|
||||
})
|
||||
|
||||
const showDescription = computed(() => {
|
||||
// Description only at full detail
|
||||
return lodLevel.value === 'full' // Maximum zoom level
|
||||
})
|
||||
|
||||
// You can also use LOD for styling
|
||||
const widgetClasses = computed(() => {
|
||||
const classes = ['number-widget']
|
||||
|
||||
if (lodLevel.value === 'minimal') {
|
||||
classes.push('widget--minimal')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Apply different styles based on LOD */
|
||||
.widget--minimal {
|
||||
/* Simplified appearance when zoomed out */
|
||||
.widget-slider {
|
||||
height: 4px; /* Thinner slider */
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
/* Normal styling */
|
||||
.widget-slider {
|
||||
height: 8px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.widget-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.widget-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
.widget-description {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## 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
|
||||
168
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal file
168
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal file
@@ -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'
|
||||
}))
|
||||
}
|
||||
}
|
||||
186
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal file
186
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal file
@@ -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
|
||||
* <NodeWidgets v-if="shouldRenderWidgets" />
|
||||
* <NodeSlots v-if="shouldRenderSlots" />
|
||||
* ```
|
||||
*/
|
||||
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, LODConfig> = {
|
||||
[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<number>) {
|
||||
// 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<LODLevel>(() => {
|
||||
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<LODConfig>(() => 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
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user