mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-20 14:30:41 +00:00
Compare commits
33 Commits
refactor/g
...
rizumu/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12d366d1d0 | ||
|
|
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 |
@@ -89,3 +89,6 @@ When referencing Comfy-Org repos:
|
||||
- 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()
|
||||
}
|
||||
}
|
||||
138
browser_tests/tests/vueNodes/NodeHeader.spec.ts
Normal file
138
browser_tests/tests/vueNodes/NodeHeader.spec.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
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.setup()
|
||||
// Load single SaveImage node workflow (positioned below menu bar)
|
||||
await comfyPage.loadWorkflow('single_save_image_node')
|
||||
})
|
||||
|
||||
test('displays node title', async ({ comfyPage }) => {
|
||||
// Get the single SaveImage node from the workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('SaveImage')
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const node = nodes[0]
|
||||
const vueNode = new VueNodeFixture(node, comfyPage.page)
|
||||
|
||||
const title = await vueNode.getTitle()
|
||||
expect(title).toBe('Save Image')
|
||||
|
||||
// Verify title is visible in the header
|
||||
const header = await vueNode.getHeader()
|
||||
await expect(header).toContainText('Save Image')
|
||||
})
|
||||
|
||||
test('allows title renaming', async ({ comfyPage }) => {
|
||||
// Get the single SaveImage node from the workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('SaveImage')
|
||||
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 single SaveImage node from the workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('SaveImage')
|
||||
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 }) => {
|
||||
// Get the single SaveImage node from the workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('SaveImage')
|
||||
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 }) => {
|
||||
// Get the single SaveImage node from the workflow
|
||||
const nodes = await comfyPage.getNodeRefsByType('SaveImage')
|
||||
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')
|
||||
})
|
||||
})
|
||||
111
docs/adr/0002-crdt-based-layout-system.md
Normal file
111
docs/adr/0002-crdt-based-layout-system.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# 2. CRDT-Based Layout System
|
||||
|
||||
Date: 2024-08-16
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
ComfyUI's node graph editor faces fundamental architectural limitations that prevent us from achieving our product goals:
|
||||
|
||||
### The Problem
|
||||
|
||||
In the current system, each node manages its own position directly within LiteGraph. This creates several critical issues:
|
||||
|
||||
1. **Performance Degradation**: Every UI update requires traversing the entire graph to detect changes. With graphs containing 100+ nodes, this polling-based approach causes visible lag during interactions.
|
||||
|
||||
2. **Snap-Back Hell**: Multiple systems (LiteGraph canvas, Vue widgets, drag handlers) fight over node positions. Users experience frustrating "snap-back" where nodes jump between positions during drag operations.
|
||||
|
||||
3. **No Collaboration Path**: Direct mutation of node positions makes real-time collaboration impossible. There's no way to merge concurrent edits from multiple users without conflicts.
|
||||
|
||||
4. **Limited Renderer Options**: Position data is tightly coupled to LiteGraph's canvas renderer, blocking us from implementing WebGL rendering for large graphs or accessibility-focused DOM rendering.
|
||||
|
||||
5. **Missing Features**: Without a proper event system, we can't implement undo/redo, animation systems, or viewport culling efficiently.
|
||||
|
||||
### Why Now?
|
||||
|
||||
- User complaints about performance with large workflows are increasing
|
||||
- The AI art community expects real-time collaboration (see Figma, Miro)
|
||||
- Accessibility requirements demand alternative rendering modes
|
||||
- The technical debt is compounding with each new feature
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a centralized layout tree using CRDT (Conflict-free Replicated Data Types) as the single source of truth for all spatial data.
|
||||
|
||||
### Key Design Choices
|
||||
|
||||
1. **CRDT-Based Layout Tree**: Use Yjs to maintain a centralized tree structure that owns all node positions, sizes, and spatial relationships.
|
||||
|
||||
2. **Command Pattern**: Every position change is an explicit command/operation rather than direct mutation. This enables:
|
||||
- Precise operation history for undo/redo
|
||||
- Automatic conflict resolution for concurrent edits
|
||||
- Event stream for observers without polling
|
||||
|
||||
3. **Unidirectional Data Flow**:
|
||||
```
|
||||
User Input → Layout Commands → CRDT Tree → Renderers
|
||||
```
|
||||
LiteGraph becomes a pure renderer that receives position updates, never mutates them.
|
||||
|
||||
4. **Spatial Indexing**: The tree structure naturally supports a QuadTree spatial index for O(log n) viewport queries instead of O(n) full scans.
|
||||
|
||||
### Why CRDT?
|
||||
|
||||
CRDTs solve our core problems elegantly:
|
||||
- **Local-First**: Works perfectly for single-user while being collaboration-ready
|
||||
- **Automatic Conflict Resolution**: No more snap-back from competing updates
|
||||
- **Event-Driven**: Changes propagate through observers, not polling
|
||||
- **Memory Efficient**: Only changed portions of the tree are updated
|
||||
|
||||
### Implementation Approach
|
||||
|
||||
Phase 1: Build alongside existing system
|
||||
- Layout tree observes LiteGraph changes initially
|
||||
- Gradually migrate interactions to command pattern
|
||||
- Maintain full backwards compatibility
|
||||
|
||||
Phase 2: Invert control
|
||||
- Layout tree becomes source of truth
|
||||
- LiteGraph receives updates via one-way sync
|
||||
- Enable alternative renderers
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **10x Performance**: Viewport culling and spatial indexing eliminate full graph traversals
|
||||
- **Multiplayer Ready**: CRDT foundation enables real-time collaboration without architecture changes
|
||||
- **Undo/Redo**: Command pattern makes history trivial to implement
|
||||
- **Renderer Flexibility**: Clean separation allows WebGL, DOM, or hybrid rendering
|
||||
- **Developer Experience**: Clear data flow and event system simplify debugging
|
||||
|
||||
### Negative
|
||||
|
||||
- **Learning Curve**: Team needs to understand CRDT concepts and command pattern
|
||||
- **Migration Complexity**: Existing code must be carefully migrated to new system
|
||||
- **Initial Memory Overhead**: ~30KB for Yjs library + operation history storage
|
||||
|
||||
### Mitigations
|
||||
|
||||
- Provide clear migration guides and examples
|
||||
- Build compatibility layer for gradual migration
|
||||
- Implement operation history pruning for long-running sessions
|
||||
|
||||
## Notes
|
||||
|
||||
This architecture aligns with modern state management patterns seen in Figma, Linear, and other collaborative tools. The investment in CRDT infrastructure pays dividends across multiple feature areas and positions ComfyUI as a modern, collaborative AI workflow tool.
|
||||
|
||||
The command pattern also opens doors for:
|
||||
- Macro recording and playback
|
||||
- Automated testing of UI interactions
|
||||
- Remote control via API
|
||||
- AI-assisted layout optimization
|
||||
|
||||
## References
|
||||
|
||||
- [Yjs Documentation](https://docs.yjs.dev/)
|
||||
- [CRDTs: The Hard Parts](https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html)
|
||||
- [Figma's Multiplayer Technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/)
|
||||
@@ -11,7 +11,8 @@ An Architecture Decision Record captures an important architectural decision mad
|
||||
| ADR | Title | Status | Date |
|
||||
|-----|-------|--------|------|
|
||||
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
|
||||
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
|
||||
| [0002](0002-crdt-based-layout-system.md) | CRDT-Based Layout System | Accepted | 2024-08-16 |
|
||||
| [0003](0003-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
|
||||
|
||||
## Creating a New ADR
|
||||
|
||||
|
||||
@@ -129,6 +129,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",
|
||||
@@ -145,12 +147,14 @@
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
"semver": "^7.7.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"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: ^2.6.0
|
||||
version: 2.6.0
|
||||
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'}
|
||||
@@ -3220,6 +3235,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'}
|
||||
@@ -3268,6 +3287,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}
|
||||
@@ -4480,6 +4503,9 @@ packages:
|
||||
isexe@2.0.0:
|
||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||
|
||||
isomorphic.js@0.2.5:
|
||||
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||
|
||||
jackspeak@3.4.0:
|
||||
resolution: {integrity: sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -4641,6 +4667,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==}
|
||||
|
||||
@@ -6049,6 +6080,9 @@ packages:
|
||||
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
|
||||
engines: {node: ^14.18.0 || >=16.0.0}
|
||||
|
||||
tailwind-merge@2.6.0:
|
||||
resolution: {integrity: sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==}
|
||||
|
||||
tailwindcss@3.4.4:
|
||||
resolution: {integrity: sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -6739,6 +6773,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'}
|
||||
@@ -8389,6 +8427,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
|
||||
@@ -10247,6 +10287,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:
|
||||
@@ -10294,6 +10338,8 @@ snapshots:
|
||||
|
||||
clone@1.0.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
code-excerpt@4.0.0:
|
||||
dependencies:
|
||||
convert-to-spaces: 2.0.1
|
||||
@@ -11563,6 +11609,8 @@ snapshots:
|
||||
|
||||
isexe@2.0.0: {}
|
||||
|
||||
isomorphic.js@0.2.5: {}
|
||||
|
||||
jackspeak@3.4.0:
|
||||
dependencies:
|
||||
'@isaacs/cliui': 8.0.2
|
||||
@@ -11728,6 +11776,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
|
||||
@@ -13505,6 +13557,8 @@ snapshots:
|
||||
'@pkgr/core': 0.1.2
|
||||
tslib: 2.8.1
|
||||
|
||||
tailwind-merge@2.6.0: {}
|
||||
|
||||
tailwindcss@3.4.4:
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
@@ -14209,6 +14263,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: {}
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
@layer primevue, tailwind-utilities;
|
||||
|
||||
@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;
|
||||
@@ -27,7 +83,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);
|
||||
@@ -134,6 +190,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 */
|
||||
@@ -638,3 +876,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,55 @@
|
||||
class="w-full h-full touch-none"
|
||||
/>
|
||||
|
||||
<!-- TransformPane for Vue node rendering (development) -->
|
||||
<TransformPane
|
||||
v-if="transformPaneEnabled && canvasStore.canvas && comfyAppReady"
|
||||
:canvas="canvasStore.canvas as LGraphCanvas"
|
||||
:viewport="canvasViewport"
|
||||
:show-debug-overlay="showPerformanceOverlay"
|
||||
@raf-status-change="rafActive = $event"
|
||||
@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>
|
||||
|
||||
<!-- Debug Panel (Development Only) -->
|
||||
<VueNodeDebugPanel
|
||||
v-if="debugPanelVisible"
|
||||
v-model:debug-override-vue-nodes="debugOverrideVueNodes"
|
||||
v-model:show-performance-overlay="showPerformanceOverlay"
|
||||
:canvas-viewport="canvasViewport"
|
||||
:vue-nodes-count="vueNodesCount"
|
||||
:nodes-in-viewport="nodesInViewport"
|
||||
:performance-metrics="performanceMetrics"
|
||||
:current-f-p-s="currentFPS"
|
||||
:last-transform-time="lastTransformTime"
|
||||
:raf-active="rafActive"
|
||||
:is-dev-mode-enabled="isDevModeEnabled"
|
||||
:should-render-vue-nodes="shouldRenderVueNodes"
|
||||
:transform-pane-enabled="transformPaneEnabled"
|
||||
/>
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
|
||||
@@ -39,13 +88,23 @@
|
||||
<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,
|
||||
reactive,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
@@ -55,14 +114,23 @@ 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 VueNodeDebugPanel from '@/components/graph/debug/VueNodeDebugPanel.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'
|
||||
import { useCopy } from '@/composables/useCopy'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
|
||||
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
|
||||
import { usePaste } from '@/composables/usePaste'
|
||||
@@ -70,7 +138,14 @@ 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 { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
|
||||
import { useLayout } from '@/renderer/core/layout/sync/useLayout'
|
||||
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 +177,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
const toastStore = useToastStore()
|
||||
const { mutations: layoutMutations } = useLayout()
|
||||
const betaMenuEnabled = computed(
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
@@ -118,6 +194,405 @@ const selectionToolboxEnabled = computed(() =>
|
||||
|
||||
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags
|
||||
const { shouldRenderVueNodes, isDevModeEnabled } = useFeatureFlags()
|
||||
|
||||
// TransformPane enabled when Vue nodes are enabled OR debug override
|
||||
const debugOverrideVueNodes = ref(false)
|
||||
// Persist debug panel visibility in settings so core commands can toggle it
|
||||
const debugPanelVisible = computed({
|
||||
get: () => settingStore.get('Comfy.VueNodes.DebugPanel.Visible') ?? false,
|
||||
set: (v: boolean) => {
|
||||
void settingStore.set('Comfy.VueNodes.DebugPanel.Visible', v)
|
||||
}
|
||||
})
|
||||
const transformPaneEnabled = computed(
|
||||
() => shouldRenderVueNodes.value || debugOverrideVueNodes.value
|
||||
)
|
||||
// Account for browser zoom/DPI scaling
|
||||
const getActualViewport = () => {
|
||||
// Get the actual canvas element dimensions which account for zoom
|
||||
const canvas = canvasRef.value
|
||||
if (canvas) {
|
||||
return {
|
||||
width: canvas.clientWidth,
|
||||
height: canvas.clientHeight
|
||||
}
|
||||
}
|
||||
// Fallback to window dimensions
|
||||
return {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
const canvasViewport = ref(getActualViewport())
|
||||
|
||||
// Debug metrics - use shallowRef for frequently updating values
|
||||
const vueNodesCount = shallowRef(0)
|
||||
const nodesInViewport = shallowRef(0)
|
||||
const currentFPS = shallowRef(0)
|
||||
const lastTransformTime = shallowRef(0)
|
||||
const rafActive = shallowRef(false)
|
||||
|
||||
// Rendering options
|
||||
const showPerformanceOverlay = ref(false)
|
||||
|
||||
// FPS tracking
|
||||
let lastTime = performance.now()
|
||||
let frameCount = 0
|
||||
let fpsRafId: number | null = null
|
||||
|
||||
const updateFPS = () => {
|
||||
frameCount++
|
||||
const currentTime = performance.now()
|
||||
if (currentTime >= lastTime + 1000) {
|
||||
currentFPS.value = Math.round(
|
||||
(frameCount * 1000) / (currentTime - lastTime)
|
||||
)
|
||||
frameCount = 0
|
||||
lastTime = currentTime
|
||||
}
|
||||
if (transformPaneEnabled.value) {
|
||||
fpsRafId = requestAnimationFrame(updateFPS)
|
||||
}
|
||||
}
|
||||
|
||||
// Start FPS tracking when TransformPane is enabled
|
||||
watch(transformPaneEnabled, (enabled) => {
|
||||
if (enabled) {
|
||||
fpsRafId = requestAnimationFrame(updateFPS)
|
||||
} else {
|
||||
// Stop FPS tracking
|
||||
if (fpsRafId !== null) {
|
||||
cancelAnimationFrame(fpsRafId)
|
||||
fpsRafId = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Update viewport on resize
|
||||
useEventListener(window, 'resize', () => {
|
||||
canvasViewport.value = getActualViewport()
|
||||
})
|
||||
|
||||
// Also update when canvas is ready
|
||||
watch(canvasRef, () => {
|
||||
if (canvasRef.value) {
|
||||
canvasViewport.value = getActualViewport()
|
||||
}
|
||||
})
|
||||
|
||||
// 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 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 = () => {}
|
||||
const performanceMetrics = reactive({
|
||||
frameTime: 0,
|
||||
updateTime: 0,
|
||||
nodeCount: 0,
|
||||
culledCount: 0,
|
||||
adaptiveQuality: false
|
||||
})
|
||||
|
||||
// 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
|
||||
Object.assign(performanceMetrics, nodeManager.performanceMetrics)
|
||||
|
||||
// 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 slot layout sync for hit detection
|
||||
slotSync = useSlotLayoutSync()
|
||||
if (canvasStore.canvas) {
|
||||
slotSync.start(canvasStore.canvas as LGraphCanvas)
|
||||
}
|
||||
|
||||
// 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 slot layout sync
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = 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()
|
||||
// Reset metrics
|
||||
performanceMetrics.frameTime = 0
|
||||
performanceMetrics.updateTime = 0
|
||||
performanceMetrics.nodeCount = 0
|
||||
performanceMetrics.culledCount = 0
|
||||
}
|
||||
|
||||
// Watch for transformPaneEnabled to gate the node manager lifecycle
|
||||
watch(
|
||||
() => transformPaneEnabled.value && Boolean(comfyApp.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
initializeNodeManager()
|
||||
} else {
|
||||
disposeNodeManagerAndSyncs()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Transform state for viewport culling
|
||||
const { syncWithCanvas } = useTransformState()
|
||||
|
||||
// Replace problematic computed property with proper reactive system
|
||||
const nodesToRender = computed(() => {
|
||||
// Access performanceMetrics to trigger on RAF updates
|
||||
void performanceMetrics.updateTime
|
||||
// Access trigger to force re-evaluation after nodeManager initialization
|
||||
void nodeDataTrigger.value
|
||||
|
||||
if (!comfyApp.graph || !transformPaneEnabled.value) {
|
||||
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
|
||||
|
||||
// Access transform time to make this reactive to transform changes
|
||||
void lastTransformTime.value
|
||||
|
||||
// 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
|
||||
})
|
||||
|
||||
// Remove side effects from computed - use watchers instead
|
||||
watch(
|
||||
() => vueNodeData.value.size,
|
||||
(count) => {
|
||||
vueNodesCount.value = count
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => nodesToRender.value.length,
|
||||
(count) => {
|
||||
nodesInViewport.value = count
|
||||
}
|
||||
)
|
||||
|
||||
// Update performance metrics when node counts change
|
||||
watch(
|
||||
() => [vueNodeData.value.size, nodesToRender.value.length],
|
||||
([totalNodes, visibleNodes]) => {
|
||||
performanceMetrics.nodeCount = totalNodes
|
||||
performanceMetrics.culledCount = totalNodes - visibleNodes
|
||||
}
|
||||
)
|
||||
|
||||
// Integrate change detection with TransformPane RAF
|
||||
// Track previous transform to detect changes
|
||||
let lastScale = 1
|
||||
let lastOffsetX = 0
|
||||
let lastOffsetY = 0
|
||||
|
||||
const handleTransformUpdate = (time: number) => {
|
||||
lastTransformTime.value = time
|
||||
|
||||
// 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()
|
||||
|
||||
// Update performance metrics
|
||||
performanceMetrics.frameTime = time
|
||||
|
||||
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')
|
||||
})
|
||||
@@ -269,7 +744,7 @@ const loadCustomNodesI18n = async () => {
|
||||
i18n.global.mergeLocaleMessage(locale, message)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load custom nodes i18n', error)
|
||||
// Ignore i18n loading errors - not critical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,6 +761,7 @@ onMounted(async () => {
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
useFeatureFlags() // This will automatically sync Vue nodes flag with LiteGraph
|
||||
|
||||
comfyApp.vueAppReady = true
|
||||
|
||||
@@ -298,9 +774,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 +799,32 @@ onMounted(async () => {
|
||||
|
||||
comfyAppReady.value = true
|
||||
|
||||
// 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 (
|
||||
transformPaneEnabled.value &&
|
||||
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 (transformPaneEnabled.value && !nodeManager) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
// Call original handler
|
||||
if (originalOnNodeAdded) {
|
||||
originalOnNodeAdded.call(this, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comfyApp.canvas.onSelectionChange = useChainCallback(
|
||||
comfyApp.canvas.onSelectionChange,
|
||||
() => canvasStore.updateSelectedItems()
|
||||
@@ -366,4 +865,30 @@ onMounted(async () => {
|
||||
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up FPS tracking
|
||||
if (fpsRafId !== null) {
|
||||
cancelAnimationFrame(fpsRafId)
|
||||
fpsRafId = null
|
||||
}
|
||||
|
||||
// Clean up node manager
|
||||
if (nodeManager) {
|
||||
nodeManager.cleanup()
|
||||
nodeManager = null
|
||||
}
|
||||
|
||||
// Clean up slot layout sync
|
||||
if (slotSync) {
|
||||
slotSync.stop()
|
||||
slotSync = null
|
||||
}
|
||||
|
||||
// Clean up link layout sync
|
||||
if (linkSync) {
|
||||
linkSync.stop()
|
||||
linkSync = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
438
src/components/graph/TransformPane.spec.ts
Normal file
438
src/components/graph/TransformPane.spec.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
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('debug overlay', () => {
|
||||
it('should not show debug overlay by default', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('should show debug overlay when enabled', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
showDebugOverlay: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('.viewport-debug-overlay').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should display viewport dimensions in debug overlay', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
showDebugOverlay: true
|
||||
}
|
||||
})
|
||||
|
||||
const debugOverlay = wrapper.find('.viewport-debug-overlay')
|
||||
expect(debugOverlay.text()).toContain('Viewport: 1280x720')
|
||||
})
|
||||
|
||||
it('should include device pixel ratio in debug overlay', () => {
|
||||
// Mock device pixel ratio
|
||||
Object.defineProperty(window, 'devicePixelRatio', {
|
||||
writable: true,
|
||||
value: 2
|
||||
})
|
||||
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
showDebugOverlay: true
|
||||
}
|
||||
})
|
||||
|
||||
const debugOverlay = wrapper.find('.viewport-debug-overlay')
|
||||
expect(debugOverlay.text()).toContain('DPR: 2')
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewport prop handling', () => {
|
||||
it('should handle missing viewport prop', () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas,
|
||||
showDebugOverlay: true
|
||||
}
|
||||
})
|
||||
|
||||
// Should not crash when viewport is undefined
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('should update debug overlay when viewport changes', async () => {
|
||||
wrapper = mount(TransformPane, {
|
||||
props: {
|
||||
canvas: mockCanvas,
|
||||
viewport: { width: 800, height: 600 },
|
||||
showDebugOverlay: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('800x600')
|
||||
|
||||
await wrapper.setProps({
|
||||
viewport: { width: 1920, height: 1080 }
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('1920x1080')
|
||||
})
|
||||
})
|
||||
})
|
||||
137
src/components/graph/TransformPane.vue
Normal file
137
src/components/graph/TransformPane.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<template>
|
||||
<div
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Vue nodes will be rendered here -->
|
||||
<slot />
|
||||
|
||||
<!-- DEV ONLY: Viewport bounds visualization -->
|
||||
<div
|
||||
v-if="props.showDebugOverlay"
|
||||
class="viewport-debug-overlay"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
left: '10px',
|
||||
top: '10px',
|
||||
border: '2px solid red',
|
||||
width: (props.viewport?.width || 0) - 20 + 'px',
|
||||
height: (props.viewport?.height || 0) - 20 + 'px',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5
|
||||
}"
|
||||
>
|
||||
<div
|
||||
style="
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: red;
|
||||
color: white;
|
||||
padding: 2px 5px;
|
||||
font-size: 10px;
|
||||
"
|
||||
>
|
||||
Viewport: {{ props.viewport?.width }}x{{ props.viewport?.height }} DPR:
|
||||
{{ devicePixelRatio }}
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
viewport?: { width: number; height: number }
|
||||
showDebugOverlay?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<TransformPaneProps>()
|
||||
|
||||
// Get device pixel ratio for display
|
||||
const devicePixelRatio = window.devicePixelRatio || 1
|
||||
|
||||
// Transform state management
|
||||
const {
|
||||
camera,
|
||||
transformStyle,
|
||||
syncWithCanvas,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
// Transform settling detection for re-rasterization optimization
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
trackPan: true
|
||||
})
|
||||
|
||||
// Use isTransforming for the CSS class (aliased for clarity)
|
||||
const isInteracting = isTransforming
|
||||
|
||||
// Provide transform utilities to child components
|
||||
provide('transformState', {
|
||||
camera,
|
||||
canvasToScreen,
|
||||
screenToCanvas,
|
||||
isNodeInViewport
|
||||
})
|
||||
|
||||
// Event delegation for node interactions
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas transform synchronization
|
||||
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;
|
||||
contain: layout style paint;
|
||||
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>
|
||||
165
src/components/graph/debug/VueNodeDebugPanel.vue
Normal file
165
src/components/graph/debug/VueNodeDebugPanel.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
|
||||
<template>
|
||||
<!-- TransformPane Debug Controls -->
|
||||
<div
|
||||
class="fixed top-20 right-4 bg-surface-0 dark-theme:bg-surface-800 p-4 rounded-lg shadow-lg border border-surface-300 dark-theme:border-surface-600 z-50 pointer-events-auto w-80"
|
||||
style="contain: layout style"
|
||||
>
|
||||
<h3 class="font-bold mb-2 text-sm">TransformPane Debug</h3>
|
||||
<div class="space-y-2 text-xs">
|
||||
<div>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="debugOverrideVueNodes" type="checkbox" />
|
||||
<span>Enable TransformPane</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Canvas Metrics -->
|
||||
<div
|
||||
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<h4 class="font-semibold mb-1">Canvas State</h4>
|
||||
<p class="text-muted">
|
||||
Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
Viewport: {{ Math.round(canvasViewport.width) }}x{{
|
||||
Math.round(canvasViewport.height)
|
||||
}}
|
||||
</p>
|
||||
<template v-if="canvasStore.canvas?.ds">
|
||||
<p class="text-muted">
|
||||
Offset: ({{ Math.round(canvasStore.canvas.ds.offset[0]) }},
|
||||
{{ Math.round(canvasStore.canvas.ds.offset[1]) }})
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
Scale: {{ canvasStore.canvas.ds.scale?.toFixed(3) || 1 }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Node Metrics -->
|
||||
<div
|
||||
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<h4 class="font-semibold mb-1">Graph Metrics</h4>
|
||||
<p class="text-muted">
|
||||
Total Nodes: {{ comfyApp.graph?.nodes?.length || 0 }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
Selected Nodes: {{ canvasStore.canvas?.selectedItems?.size || 0 }}
|
||||
</p>
|
||||
<p class="text-muted">Vue Nodes Rendered: {{ vueNodesCount }}</p>
|
||||
<p class="text-muted">Nodes in Viewport: {{ nodesInViewport }}</p>
|
||||
<p class="text-muted">
|
||||
Culled Nodes: {{ performanceMetrics.culledCount }}
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
Cull Percentage:
|
||||
{{
|
||||
Math.round(
|
||||
((vueNodesCount - nodesInViewport) / Math.max(vueNodesCount, 1)) *
|
||||
100
|
||||
)
|
||||
}}%
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Performance Metrics -->
|
||||
<div
|
||||
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<h4 class="font-semibold mb-1">Performance</h4>
|
||||
<p v-memo="[currentFPS]" class="text-muted">FPS: {{ currentFPS }}</p>
|
||||
<p v-memo="[Math.round(lastTransformTime)]" class="text-muted">
|
||||
Transform Update: {{ Math.round(lastTransformTime) }}ms
|
||||
</p>
|
||||
<p
|
||||
v-memo="[Math.round(performanceMetrics.updateTime)]"
|
||||
class="text-muted"
|
||||
>
|
||||
Lifecycle Update: {{ Math.round(performanceMetrics.updateTime) }}ms
|
||||
</p>
|
||||
<p v-memo="[rafActive]" class="text-muted">
|
||||
RAF Active: {{ rafActive ? 'Yes' : 'No' }}
|
||||
</p>
|
||||
<p v-memo="[performanceMetrics.adaptiveQuality]" class="text-muted">
|
||||
Adaptive Quality:
|
||||
{{ performanceMetrics.adaptiveQuality ? 'On' : 'Off' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Flags Status -->
|
||||
<div
|
||||
v-if="isDevModeEnabled"
|
||||
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<h4 class="font-semibold mb-1">Feature Flags</h4>
|
||||
<p class="text-muted text-xs">
|
||||
Vue Nodes: {{ shouldRenderVueNodes ? 'Enabled' : 'Disabled' }}
|
||||
</p>
|
||||
<p class="text-muted text-xs">
|
||||
Dev Mode: {{ isDevModeEnabled ? 'Enabled' : 'Disabled' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Performance Options -->
|
||||
<div
|
||||
v-if="transformPaneEnabled"
|
||||
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
|
||||
>
|
||||
<h4 class="font-semibold mb-1">Debug Options</h4>
|
||||
<label class="flex items-center gap-2">
|
||||
<input v-model="showPerformanceOverlay" type="checkbox" />
|
||||
<span>Show Performance Overlay</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
interface Props {
|
||||
debugOverrideVueNodes: boolean
|
||||
canvasViewport: { width: number; height: number }
|
||||
vueNodesCount: number
|
||||
nodesInViewport: number
|
||||
performanceMetrics: {
|
||||
culledCount: number
|
||||
updateTime: number
|
||||
adaptiveQuality: boolean
|
||||
}
|
||||
currentFPS: number
|
||||
lastTransformTime: number
|
||||
rafActive: boolean
|
||||
isDevModeEnabled: boolean
|
||||
shouldRenderVueNodes: boolean
|
||||
transformPaneEnabled: boolean
|
||||
showPerformanceOverlay: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:debugOverrideVueNodes', value: boolean): void
|
||||
(e: 'update:showPerformanceOverlay', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
const debugOverrideVueNodes = computed({
|
||||
get: () => props.debugOverrideVueNodes,
|
||||
set: (value: boolean) => emit('update:debugOverrideVueNodes', value)
|
||||
})
|
||||
|
||||
const showPerformanceOverlay = computed({
|
||||
get: () => props.showPerformanceOverlay,
|
||||
set: (value: boolean) => emit('update:showPerformanceOverlay', value)
|
||||
})
|
||||
</script>
|
||||
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
|
||||
}
|
||||
}
|
||||
211
src/composables/graph/README.md
Normal file
211
src/composables/graph/README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# Graph Composables - Reactive Layout System
|
||||
|
||||
This directory contains composables for the reactive layout system, enabling Vue nodes to handle their own interactions while maintaining synchronization with LiteGraph.
|
||||
|
||||
## Composable Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Composables"
|
||||
URL[useReactiveLayout<br/>- Singleton Management<br/>- Service Access]
|
||||
UVNI[useVueNodeInteraction<br/>- Node Dragging<br/>- CSS Transforms]
|
||||
ULGS[useLiteGraphSync<br/>- Bidirectional Sync<br/>- Position Updates]
|
||||
end
|
||||
|
||||
subgraph "Services"
|
||||
LT[ReactiveLayoutTree]
|
||||
HT[ReactiveHitTester]
|
||||
end
|
||||
|
||||
subgraph "Components"
|
||||
GC[GraphCanvas]
|
||||
VN[Vue Nodes]
|
||||
TP[TransformPane]
|
||||
end
|
||||
|
||||
URL --> LT
|
||||
URL --> HT
|
||||
UVNI --> URL
|
||||
ULGS --> URL
|
||||
|
||||
GC --> ULGS
|
||||
VN --> UVNI
|
||||
TP --> URL
|
||||
</mermaid>
|
||||
|
||||
## Interaction Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant VueNode
|
||||
participant UVNI as useVueNodeInteraction
|
||||
participant LT as LayoutTree
|
||||
participant LG as LiteGraph
|
||||
|
||||
User->>VueNode: pointerdown
|
||||
VueNode->>UVNI: startDrag(event)
|
||||
UVNI->>UVNI: Set drag state
|
||||
UVNI->>UVNI: Capture pointer
|
||||
|
||||
User->>VueNode: pointermove
|
||||
VueNode->>UVNI: handleDrag(event)
|
||||
UVNI->>UVNI: Calculate delta
|
||||
UVNI->>VueNode: Update CSS transform
|
||||
Note over VueNode: Visual feedback only
|
||||
|
||||
User->>VueNode: pointerup
|
||||
VueNode->>UVNI: endDrag(event)
|
||||
UVNI->>LT: updateNodePosition(finalPos)
|
||||
LT->>LG: Trigger reactive sync
|
||||
LG->>LG: Update canvas
|
||||
```
|
||||
|
||||
## useReactiveLayout
|
||||
|
||||
Singleton management for the reactive layout system.
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class useReactiveLayout {
|
||||
+layoutTree: ComputedRef~ReactiveLayoutTree~
|
||||
+hitTester: ComputedRef~ReactiveHitTester~
|
||||
+nodePositions: ComputedRef~Map~
|
||||
+nodeBounds: ComputedRef~Map~
|
||||
+selectedNodes: ComputedRef~Set~
|
||||
-initialize(): void
|
||||
}
|
||||
|
||||
class Singleton {
|
||||
<<pattern>>
|
||||
Shared across all components
|
||||
}
|
||||
|
||||
useReactiveLayout --> Singleton : implements
|
||||
```
|
||||
|
||||
## useVueNodeInteraction
|
||||
|
||||
Handles individual node interactions with CSS transforms.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph "Drag State"
|
||||
DS[isDragging<br/>dragDelta<br/>dragStartPos]
|
||||
end
|
||||
|
||||
subgraph "Event Handlers"
|
||||
SD[startDrag]
|
||||
HD[handleDrag]
|
||||
ED[endDrag]
|
||||
end
|
||||
|
||||
subgraph "Computed Styles"
|
||||
NS[nodeStyle<br/>- position<br/>- dimensions<br/>- z-index]
|
||||
DGS[dragStyle<br/>- transform<br/>- transition]
|
||||
end
|
||||
|
||||
SD --> DS
|
||||
HD --> DS
|
||||
ED --> DS
|
||||
|
||||
DS --> NS
|
||||
DS --> DGS
|
||||
```
|
||||
|
||||
### Transform Calculation
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Mouse Delta"
|
||||
MD[event.clientX/Y - startMouse]
|
||||
end
|
||||
|
||||
subgraph "Canvas Transform"
|
||||
CT[screenToCanvas conversion]
|
||||
end
|
||||
|
||||
subgraph "Drag Delta"
|
||||
DD[Canvas-space delta]
|
||||
end
|
||||
|
||||
subgraph "CSS Transform"
|
||||
CSS[translate(deltaX, deltaY)]
|
||||
end
|
||||
|
||||
MD --> CT
|
||||
CT --> DD
|
||||
DD --> CSS
|
||||
```
|
||||
|
||||
## useLiteGraphSync
|
||||
|
||||
Bidirectional synchronization between LiteGraph and the reactive layout tree.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Initialize
|
||||
|
||||
Initialize --> SyncFromLiteGraph
|
||||
SyncFromLiteGraph --> WatchLayoutTree
|
||||
|
||||
state WatchLayoutTree {
|
||||
[*] --> Listening
|
||||
Listening --> PositionChanged: Layout tree update
|
||||
PositionChanged --> UpdateLiteGraph
|
||||
UpdateLiteGraph --> TriggerRedraw
|
||||
TriggerRedraw --> Listening
|
||||
}
|
||||
|
||||
state SyncFromLiteGraph {
|
||||
[*] --> ReadNodes
|
||||
ReadNodes --> UpdateLayoutTree
|
||||
UpdateLayoutTree --> [*]
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Example
|
||||
|
||||
```typescript
|
||||
// In GraphCanvas.vue
|
||||
const { initializeSync } = useLiteGraphSync()
|
||||
onMounted(() => {
|
||||
initializeSync() // Start bidirectional sync
|
||||
})
|
||||
|
||||
// In LGraphNode.vue
|
||||
const {
|
||||
isDragging,
|
||||
startDrag,
|
||||
handleDrag,
|
||||
endDrag,
|
||||
dragStyle,
|
||||
updatePosition
|
||||
} = useVueNodeInteraction(props.nodeData.id)
|
||||
|
||||
// Template
|
||||
<div
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
// ... other styles
|
||||
},
|
||||
dragStyle // Applied during drag
|
||||
]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
>
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **CSS Transforms During Drag**: No layout recalculation, GPU accelerated
|
||||
2. **Batch Position Updates**: Layout tree updates trigger single LiteGraph sync
|
||||
3. **Reactive Efficiency**: Vue's computed properties cache results
|
||||
4. **Spatial Indexing**: QuadTree integration for fast hit testing
|
||||
|
||||
## Future Migration Path
|
||||
|
||||
Currently: Vue nodes use CSS transforms, commit to layout tree on drag end
|
||||
Future: Each renderer owns complete interaction handling and layout state
|
||||
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
|
||||
}
|
||||
}
|
||||
186
src/composables/graph/useEventForwarding.ts
Normal file
186
src/composables/graph/useEventForwarding.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export function useEventForwarding() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Track active drag operation
|
||||
let isDragging = false
|
||||
let dragCleanup: (() => void) | null = null
|
||||
// Store last known position for escape key handling
|
||||
const lastPointerPosition = { x: 0, y: 0 }
|
||||
|
||||
function createSyntheticPointerEvent(
|
||||
originalEvent: PointerEvent,
|
||||
eventType: string
|
||||
): PointerEvent {
|
||||
// Only copy properties that LiteGraph actually uses
|
||||
return new PointerEvent(eventType, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
// Position properties
|
||||
clientX: originalEvent.clientX,
|
||||
clientY: originalEvent.clientY,
|
||||
// Modifier keys
|
||||
ctrlKey: originalEvent.ctrlKey,
|
||||
shiftKey: originalEvent.shiftKey,
|
||||
altKey: originalEvent.altKey,
|
||||
metaKey: originalEvent.metaKey,
|
||||
// Button state
|
||||
button: originalEvent.button,
|
||||
buttons: originalEvent.buttons,
|
||||
// Pointer tracking
|
||||
pointerId: originalEvent.pointerId,
|
||||
isPrimary: originalEvent.isPrimary,
|
||||
pointerType: originalEvent.pointerType
|
||||
})
|
||||
}
|
||||
|
||||
function forwardPointerEvent(
|
||||
originalEvent: PointerEvent,
|
||||
eventType: 'down' | 'move' | 'up'
|
||||
) {
|
||||
const canvas: LGraphCanvas | null = canvasStore.getCanvas()
|
||||
if (!canvas) {
|
||||
console.warn('No canvas available for event forwarding')
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent original event from bubbling to canvas
|
||||
originalEvent.stopPropagation()
|
||||
originalEvent.preventDefault()
|
||||
|
||||
// Create synthetic event
|
||||
const syntheticEvent = createSyntheticPointerEvent(
|
||||
originalEvent,
|
||||
`pointer${eventType}`
|
||||
)
|
||||
|
||||
// Create a mutable copy of the event for LiteGraph to modify
|
||||
const mutableEvent = syntheticEvent as PointerEvent & {
|
||||
canvasX?: number
|
||||
canvasY?: number
|
||||
deltaX?: number
|
||||
deltaY?: number
|
||||
safeOffsetX?: number
|
||||
safeOffsetY?: number
|
||||
}
|
||||
|
||||
// Let LiteGraph adjust coordinates to graph space
|
||||
// Using 'as any' to bypass TypeScript assertion limitations
|
||||
;(canvas.adjustMouseEvent as any)(mutableEvent)
|
||||
|
||||
// Forward to appropriate handler
|
||||
switch (eventType) {
|
||||
case 'down':
|
||||
canvas.processMouseDown(mutableEvent)
|
||||
break
|
||||
case 'move':
|
||||
canvas.processMouseMove(mutableEvent)
|
||||
break
|
||||
case 'up':
|
||||
canvas.processMouseUp(mutableEvent)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-create event handlers to avoid recreating on each pointerdown
|
||||
const handlePointerMove = (e: PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
// Update last known position
|
||||
lastPointerPosition.x = e.clientX
|
||||
lastPointerPosition.y = e.clientY
|
||||
forwardPointerEvent(e, 'move')
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
|
||||
isDragging = false
|
||||
forwardPointerEvent(e, 'up')
|
||||
|
||||
// Clean up listeners
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Handle escape key to cancel drag
|
||||
if (e.key === 'Escape' && isDragging) {
|
||||
isDragging = false
|
||||
|
||||
// Create minimal synthetic cancel event
|
||||
const cancelEvent = new PointerEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
view: window,
|
||||
// Use last known position from the current drag operation
|
||||
clientX: lastPointerPosition.x,
|
||||
clientY: lastPointerPosition.y,
|
||||
button: 0,
|
||||
buttons: 0
|
||||
})
|
||||
|
||||
const canvas: LGraphCanvas | null = canvasStore.getCanvas()
|
||||
if (canvas) {
|
||||
const mutableCancelEvent = cancelEvent as PointerEvent & {
|
||||
canvasX?: number
|
||||
canvasY?: number
|
||||
deltaX?: number
|
||||
deltaY?: number
|
||||
safeOffsetX?: number
|
||||
safeOffsetY?: number
|
||||
}
|
||||
;(canvas.adjustMouseEvent as any)(mutableCancelEvent)
|
||||
canvas.processMouseUp(mutableCancelEvent)
|
||||
}
|
||||
|
||||
// Clean up
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlotPointerDown(originalEvent: PointerEvent) {
|
||||
// Forward the initial pointer down
|
||||
forwardPointerEvent(originalEvent, 'down')
|
||||
|
||||
// Set up drag handling
|
||||
isDragging = true
|
||||
// Initialize last known position
|
||||
lastPointerPosition.x = originalEvent.clientX
|
||||
lastPointerPosition.y = originalEvent.clientY
|
||||
|
||||
// Add global listeners for drag handling
|
||||
document.addEventListener('pointermove', handlePointerMove, true)
|
||||
document.addEventListener('pointerup', handlePointerUp, true)
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
|
||||
// Store cleanup function
|
||||
dragCleanup = () => {
|
||||
document.removeEventListener('pointermove', handlePointerMove, true)
|
||||
document.removeEventListener('pointerup', handlePointerUp, true)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
function cleanup() {
|
||||
isDragging = false
|
||||
if (dragCleanup) {
|
||||
dragCleanup()
|
||||
dragCleanup = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSlotPointerDown,
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
810
src/composables/graph/useGraphNodeManager.ts
Normal file
810
src/composables/graph/useGraphNodeManager.ts
Normal file
@@ -0,0 +1,810 @@
|
||||
/**
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { nextTick, reactive, readonly } from 'vue'
|
||||
|
||||
import { layoutMutations } 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 => {
|
||||
// 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 layoutMutations.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 layoutMutations.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 - now simplified with extracted helpers
|
||||
*/
|
||||
const detectChangesInRAF = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (!graph?._nodes) return
|
||||
|
||||
let positionUpdates = 0
|
||||
let sizeUpdates = 0
|
||||
|
||||
// Set source for all canvas-driven updates
|
||||
layoutMutations.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
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
void layoutMutations.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
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
void layoutMutations.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()
|
||||
}
|
||||
}
|
||||
212
src/composables/graph/useSpatialIndex.ts
Normal file
212
src/composables/graph/useSpatialIndex.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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
|
||||
enableDebugVisualization?: boolean
|
||||
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
|
||||
})
|
||||
|
||||
// Debug visualization data (unused for now but may be used in future)
|
||||
// const debugBounds = ref<Bounds[]>([])
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Get debug visualization data
|
||||
const getDebugVisualization = () => {
|
||||
if (!quadTree.value || !options.enableDebugVisualization) return null
|
||||
|
||||
return quadTree.value.getDebugInfo()
|
||||
}
|
||||
|
||||
// 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),
|
||||
|
||||
// Debug
|
||||
getDebugVisualization,
|
||||
|
||||
// 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'
|
||||
|
||||
|
||||
@@ -276,6 +276,33 @@ 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: 'Experimental.ToggleVueNodeDebugPanel',
|
||||
label: () =>
|
||||
`Experimental: ${
|
||||
useSettingStore().get('Comfy.VueNodes.DebugPanel.Visible')
|
||||
? 'Hide'
|
||||
: 'Show'
|
||||
} Vue Node Debug Panel`,
|
||||
function: async () => {
|
||||
const settingStore = useSettingStore()
|
||||
const current =
|
||||
settingStore.get('Comfy.VueNodes.DebugPanel.Visible') ?? false
|
||||
await settingStore.set('Comfy.VueNodes.DebugPanel.Visible', !current)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.FitView',
|
||||
icon: 'pi pi-expand',
|
||||
|
||||
70
src/composables/useFeatureFlags.ts
Normal file
70
src/composables/useFeatureFlags.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Feature flags composable for Vue node system
|
||||
* Provides safe toggles for experimental features
|
||||
*/
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
export const useFeatureFlags = () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
/**
|
||||
* Enable Vue-based node rendering
|
||||
* When disabled, falls back to standard LiteGraph canvas rendering
|
||||
*/
|
||||
const isVueNodesEnabled = computed(() => {
|
||||
try {
|
||||
// Off by default: ensure Vue nodes are disabled unless explicitly enabled
|
||||
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Development mode features (debug panel, etc.)
|
||||
* Automatically enabled in development builds
|
||||
*/
|
||||
const isDevModeEnabled = computed(() => {
|
||||
try {
|
||||
return (
|
||||
settingStore.get('Comfy.DevMode' as any) ??
|
||||
process.env.NODE_ENV === 'development'
|
||||
)
|
||||
} catch {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Check if Vue nodes should be rendered at all
|
||||
* Combines multiple conditions for safety
|
||||
*/
|
||||
const shouldRenderVueNodes = computed(
|
||||
() =>
|
||||
isVueNodesEnabled.value &&
|
||||
// Add any other safety conditions here
|
||||
true
|
||||
)
|
||||
|
||||
/**
|
||||
* Sync the Vue nodes feature flag with LiteGraph global settings
|
||||
*/
|
||||
const syncVueNodesFlag = () => {
|
||||
LiteGraph.vueNodesMode = isVueNodesEnabled.value
|
||||
console.log('Vue nodes mode:', LiteGraph.vueNodesMode)
|
||||
}
|
||||
|
||||
// Watch for changes and update LiteGraph
|
||||
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
|
||||
|
||||
return {
|
||||
isVueNodesEnabled,
|
||||
isDevModeEnabled,
|
||||
shouldRenderVueNodes,
|
||||
syncVueNodesFlag
|
||||
}
|
||||
}
|
||||
@@ -928,5 +928,25 @@ 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
|
||||
},
|
||||
{
|
||||
id: 'Comfy.VueNodes.DebugPanel.Visible',
|
||||
name: 'Vue Nodes Debug Panel Visible (hidden)',
|
||||
type: 'hidden',
|
||||
defaultValue: false,
|
||||
experimental: true
|
||||
}
|
||||
]
|
||||
|
||||
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 { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
|
||||
import type { DragAndScaleState } from './DragAndScale'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
@@ -1349,6 +1351,16 @@ export class LGraph
|
||||
floatingLinkIds
|
||||
)
|
||||
this.reroutes.set(rerouteId, reroute)
|
||||
|
||||
// Register reroute in Layout Store for spatial tracking
|
||||
layoutMutations.setSource(LayoutSource.Canvas)
|
||||
layoutMutations.createReroute(
|
||||
String(rerouteId),
|
||||
{ x: pos[0], y: pos[1] },
|
||||
before.parentId ? String(before.parentId) : undefined,
|
||||
Array.from(linkIds)
|
||||
)
|
||||
|
||||
for (const linkId of linkIds) {
|
||||
const link = this._links.get(linkId)
|
||||
if (!link) continue
|
||||
@@ -1422,6 +1434,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)
|
||||
@@ -2245,6 +2262,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='
|
||||
@@ -639,6 +640,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. */
|
||||
@@ -700,6 +704,13 @@ export class LGraphCanvas
|
||||
this.ds = new DragAndScale(canvas)
|
||||
this.pointer = new CanvasPointer(canvas)
|
||||
|
||||
// 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()
|
||||
)
|
||||
@@ -1793,6 +1804,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()
|
||||
}
|
||||
@@ -2186,11 +2202,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) => {
|
||||
@@ -2356,8 +2383,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) {
|
||||
@@ -2391,16 +2428,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) {
|
||||
@@ -2415,7 +2468,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)
|
||||
@@ -3128,8 +3184,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) {
|
||||
@@ -4590,18 +4665,26 @@ 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) {
|
||||
const context = this.buildLinkRenderContext()
|
||||
this.linkRenderer.renderLinkDirect(
|
||||
ctx,
|
||||
pos,
|
||||
highlightPos,
|
||||
null,
|
||||
false,
|
||||
null,
|
||||
colour,
|
||||
fromDirection,
|
||||
dragDirection,
|
||||
context,
|
||||
{
|
||||
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)
|
||||
@@ -4674,6 +4757,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
|
||||
@@ -4999,6 +5087,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
|
||||
|
||||
@@ -5712,6 +5813,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
|
||||
@@ -5753,333 +5882,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6286,6 +6109,8 @@ export class LGraphCanvas
|
||||
: segment.id
|
||||
if (linkId !== undefined) {
|
||||
graph.removeLink(linkId)
|
||||
// Clean up layout store
|
||||
layoutStore.deleteLinkLayout(linkId)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -8363,11 +8188,26 @@ 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) {
|
||||
console.debug('✅ Using LayoutStore for reroute query', {
|
||||
rerouteLayout
|
||||
})
|
||||
reroute = this.graph.getReroute(rerouteLayout.id)
|
||||
} else {
|
||||
console.debug('⚠️ Falling back to old reroute query method')
|
||||
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 { layoutMutations } 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
|
||||
}
|
||||
@@ -2791,6 +2844,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)
|
||||
@@ -3192,6 +3255,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.
|
||||
*
|
||||
@@ -3200,7 +3282,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3209,25 +3291,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3238,29 +3302,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 */
|
||||
@@ -3806,12 +3848,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 {
|
||||
@@ -4007,14 +4070,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 { layoutMutations } 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,7 +16,6 @@ import type {
|
||||
LinkSegment,
|
||||
ReadonlyLinkNetwork
|
||||
} from './interfaces'
|
||||
import { Subgraph } from './litegraph'
|
||||
import type {
|
||||
Serialisable,
|
||||
SerialisableLLink,
|
||||
@@ -460,19 +461,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,32 @@ 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
|
||||
|
||||
/**
|
||||
* Type for component height keys
|
||||
*/
|
||||
export type ComponentHeightKey =
|
||||
keyof typeof COMFY_VUE_NODE_DIMENSIONS.components
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
*/
|
||||
@@ -75,6 +101,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 +364,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 { layoutMutations } 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'
|
||||
@@ -407,8 +410,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,11 @@ 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,
|
||||
type ComponentHeightKey
|
||||
} 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/ImageWidget.ts
Normal file
50
src/lib/litegraph/src/widgets/ImageWidget.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { IImageWidget } from '../types/widgets'
|
||||
import {
|
||||
BaseWidget,
|
||||
type DrawWidgetOptions,
|
||||
type WidgetEventOptions
|
||||
} from './BaseWidget'
|
||||
|
||||
/**
|
||||
* Widget for displaying images
|
||||
* This is a widget that only has a Vue widgets implementation
|
||||
*/
|
||||
export class ImageWidget
|
||||
extends BaseWidget<IImageWidget>
|
||||
implements IImageWidget
|
||||
{
|
||||
override type = 'image' 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 = 'Image: 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
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,23 @@ import type {
|
||||
IBaseWidget,
|
||||
IBooleanWidget,
|
||||
IButtonWidget,
|
||||
IChartWidget,
|
||||
IColorWidget,
|
||||
IComboWidget,
|
||||
ICustomWidget,
|
||||
IFileUploadWidget,
|
||||
IGalleriaWidget,
|
||||
IImageCompareWidget,
|
||||
IImageWidget,
|
||||
IKnobWidget,
|
||||
IMarkdownWidget,
|
||||
IMultiSelectWidget,
|
||||
INumericWidget,
|
||||
ISelectButtonWidget,
|
||||
ISliderWidget,
|
||||
IStringWidget,
|
||||
ITextareaWidget,
|
||||
ITreeSelectWidget,
|
||||
IWidget,
|
||||
TWidgetType
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -17,12 +28,23 @@ 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 { ImageWidget } from './ImageWidget'
|
||||
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 +56,17 @@ export type WidgetTypeMap = {
|
||||
string: TextWidget
|
||||
text: TextWidget
|
||||
custom: LegacyWidget
|
||||
fileupload: FileUploadWidget
|
||||
color: ColorWidget
|
||||
markdown: MarkdownWidget
|
||||
image: ImageWidget
|
||||
treeselect: TreeSelectWidget
|
||||
multiselect: MultiSelectWidget
|
||||
chart: ChartWidget
|
||||
galleria: GalleriaWidget
|
||||
imagecompare: ImageCompareWidget
|
||||
selectbutton: SelectButtonWidget
|
||||
textarea: TextareaWidget
|
||||
[key: string]: BaseWidget
|
||||
}
|
||||
|
||||
@@ -82,6 +115,28 @@ 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 'image':
|
||||
return toClass(ImageWidget, 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)
|
||||
}
|
||||
@@ -135,4 +190,75 @@ export function isCustomWidget(widget: IBaseWidget): widget is ICustomWidget {
|
||||
return widget.type === 'custom'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IFileUploadWidget}. */
|
||||
export function isFileUploadWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IFileUploadWidget {
|
||||
return widget.type === 'fileupload'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IColorWidget}. */
|
||||
export function isColorWidget(widget: IBaseWidget): widget is IColorWidget {
|
||||
return widget.type === 'color'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IMarkdownWidget}. */
|
||||
export function isMarkdownWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IMarkdownWidget {
|
||||
return widget.type === 'markdown'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IImageWidget}. */
|
||||
export function isImageWidget(widget: IBaseWidget): widget is IImageWidget {
|
||||
return widget.type === 'image'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ITreeSelectWidget}. */
|
||||
export function isTreeSelectWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is ITreeSelectWidget {
|
||||
return widget.type === 'treeselect'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IMultiSelectWidget}. */
|
||||
export function isMultiSelectWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IMultiSelectWidget {
|
||||
return widget.type === 'multiselect'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IChartWidget}. */
|
||||
export function isChartWidget(widget: IBaseWidget): widget is IChartWidget {
|
||||
return widget.type === 'chart'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IGalleriaWidget}. */
|
||||
export function isGalleriaWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IGalleriaWidget {
|
||||
return widget.type === 'galleria'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IImageCompareWidget}. */
|
||||
export function isImageCompareWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is IImageCompareWidget {
|
||||
return widget.type === 'imagecompare'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ISelectButtonWidget}. */
|
||||
export function isSelectButtonWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is ISelectButtonWidget {
|
||||
return widget.type === 'selectbutton'
|
||||
}
|
||||
|
||||
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ITextareaWidget}. */
|
||||
export function isTextareaWidget(
|
||||
widget: IBaseWidget
|
||||
): widget is ITextareaWidget {
|
||||
return widget.type === 'textarea'
|
||||
}
|
||||
|
||||
// #endregion Type Guards
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -329,3 +329,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,
|
||||
@@ -134,6 +135,7 @@ LGraph {
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
@@ -207,6 +209,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,
|
||||
@@ -132,6 +133,7 @@ LGraph {
|
||||
"bgcolor": undefined,
|
||||
"block_delete": undefined,
|
||||
"boxcolor": undefined,
|
||||
"changeTracker": undefined,
|
||||
"clip_area": undefined,
|
||||
"clonable": undefined,
|
||||
"color": undefined,
|
||||
@@ -203,6 +205,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",
|
||||
@@ -1043,16 +1044,26 @@
|
||||
"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"
|
||||
"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",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
|
||||
"sideToolbar_queue": "sideToolbar.queue",
|
||||
"sideToolbar_workflows": "sideToolbar.workflows"
|
||||
},
|
||||
"desktopMenu": {
|
||||
"reinstall": "Reinstall",
|
||||
@@ -1112,7 +1123,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.",
|
||||
|
||||
@@ -267,7 +267,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"
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "Deshabilitando",
|
||||
"dismiss": "Descartar",
|
||||
"download": "Descargar",
|
||||
"dropYourFileOr": "Suelta tu archivo o",
|
||||
"duplicate": "Duplicar",
|
||||
"edit": "Editar",
|
||||
"empty": "Vacío",
|
||||
@@ -852,11 +853,14 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -873,7 +877,11 @@
|
||||
"renderBypassState": "Mostrar estado de omisión",
|
||||
"renderErrorState": "Mostrar estado de error",
|
||||
"showGroups": "Mostrar marcos/grupos",
|
||||
"showLinks": "Mostrar enlaces"
|
||||
"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",
|
||||
@@ -1190,6 +1198,7 @@
|
||||
"UV": "UV",
|
||||
"User": "Usuario",
|
||||
"Validation": "Validación",
|
||||
"Vue Nodes": "Nodos Vue",
|
||||
"Window": "Ventana",
|
||||
"Workflow": "Flujo de Trabajo"
|
||||
},
|
||||
@@ -1708,4 +1717,4 @@
|
||||
"showMinimap": "Mostrar minimapa",
|
||||
"zoomToFit": "Ajustar al zoom"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "Désactivation",
|
||||
"dismiss": "Fermer",
|
||||
"download": "Télécharger",
|
||||
"dropYourFileOr": "Déposez votre fichier ou",
|
||||
"duplicate": "Dupliquer",
|
||||
"edit": "Modifier",
|
||||
"empty": "Vide",
|
||||
@@ -852,11 +853,14 @@
|
||||
"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 Search Box": "Basculer la boîte de recherche",
|
||||
"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",
|
||||
@@ -866,14 +870,19 @@
|
||||
"Workflows": "Flux de travail",
|
||||
"Zoom In": "Zoom avant",
|
||||
"Zoom Out": "Zoom arrière",
|
||||
"Zoom to fit": "Ajuster à l’écran"
|
||||
"Zoom to fit": "Ajuster à l'écran"
|
||||
},
|
||||
"minimap": {
|
||||
"nodeColors": "Couleurs des nœuds",
|
||||
"renderBypassState": "Afficher l’état de contournement",
|
||||
"renderErrorState": "Afficher l’état d’erreur",
|
||||
"renderBypassState": "Afficher l'état de contournement",
|
||||
"renderErrorState": "Afficher l'état d'erreur",
|
||||
"showGroups": "Afficher les cadres/groupes",
|
||||
"showLinks": "Afficher les liens"
|
||||
"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",
|
||||
@@ -1190,6 +1199,7 @@
|
||||
"UV": "UV",
|
||||
"User": "Utilisateur",
|
||||
"Validation": "Validation",
|
||||
"Vue Nodes": "Nœuds Vue",
|
||||
"Window": "Fenêtre",
|
||||
"Workflow": "Flux de Travail"
|
||||
},
|
||||
@@ -1708,4 +1718,4 @@
|
||||
"showMinimap": "Afficher la mini-carte",
|
||||
"zoomToFit": "Ajuster à l’écran"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "無効化",
|
||||
"dismiss": "閉じる",
|
||||
"download": "ダウンロード",
|
||||
"dropYourFileOr": "ファイルをドロップするか",
|
||||
"duplicate": "複製",
|
||||
"edit": "編集",
|
||||
"empty": "空",
|
||||
@@ -853,10 +854,15 @@
|
||||
"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": "元に戻す",
|
||||
@@ -873,7 +879,11 @@
|
||||
"renderBypassState": "バイパス状態を表示",
|
||||
"renderErrorState": "エラー状態を表示",
|
||||
"showGroups": "フレーム/グループを表示",
|
||||
"showLinks": "リンクを表示"
|
||||
"showLinks": "リンクを表示",
|
||||
"sideToolbar_modelLibrary": "モデルライブラリ",
|
||||
"sideToolbar_nodeLibrary": "ノードライブラリ",
|
||||
"sideToolbar_queue": "キュー",
|
||||
"sideToolbar_workflows": "ワークフロー"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "再度表示しない",
|
||||
@@ -1190,6 +1200,7 @@
|
||||
"UV": "UV",
|
||||
"User": "ユーザー",
|
||||
"Validation": "検証",
|
||||
"Vue Nodes": "Vueノード",
|
||||
"Window": "ウィンドウ",
|
||||
"Workflow": "ワークフロー"
|
||||
},
|
||||
@@ -1708,4 +1719,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": {
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"label": "필수 하단 패널 전환"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "보기 컨트롤 하단 패널 전환"
|
||||
"label": "뷰 컨트롤 하단 패널 전환"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "키 바인딩 대화상자 표시"
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "비활성화 중",
|
||||
"dismiss": "닫기",
|
||||
"download": "다운로드",
|
||||
"dropYourFileOr": "파일을 드롭하거나",
|
||||
"duplicate": "복제",
|
||||
"edit": "편집",
|
||||
"empty": "비어 있음",
|
||||
@@ -852,12 +853,19 @@
|
||||
"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 Workflows Sidebar": "워크플로우 사이드바 전환",
|
||||
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
|
||||
"Toggle Search Box": "검색 상자 전환",
|
||||
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
|
||||
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
|
||||
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
|
||||
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
|
||||
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환", "Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
|
||||
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
|
||||
"Undo": "실행 취소",
|
||||
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
|
||||
@@ -873,7 +881,11 @@
|
||||
"renderBypassState": "바이패스 상태 렌더링",
|
||||
"renderErrorState": "에러 상태 렌더링",
|
||||
"showGroups": "프레임/그룹 표시",
|
||||
"showLinks": "링크 표시"
|
||||
"showLinks": "링크 표시",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
|
||||
"sideToolbar_queue": "sideToolbar.대기열",
|
||||
"sideToolbar_workflows": "sideToolbar.워크플로우"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "다시 보지 않기",
|
||||
@@ -1190,6 +1202,7 @@
|
||||
"UV": "UV",
|
||||
"User": "사용자",
|
||||
"Validation": "검증",
|
||||
"Vue Nodes": "Vue 노드",
|
||||
"Window": "창",
|
||||
"Workflow": "워크플로"
|
||||
},
|
||||
@@ -1708,4 +1721,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": {
|
||||
|
||||
@@ -264,7 +264,7 @@
|
||||
"label": "Показать/скрыть основную нижнюю панель"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "Показать или скрыть нижнюю панель управления просмотром"
|
||||
"label": "Показать/скрыть нижнюю панель управления просмотром"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "Показать диалог клавиш"
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "Отключение",
|
||||
"dismiss": "Закрыть",
|
||||
"download": "Скачать",
|
||||
"dropYourFileOr": "Перетащите ваш файл или",
|
||||
"duplicate": "Дублировать",
|
||||
"edit": "Редактировать",
|
||||
"empty": "Пусто",
|
||||
@@ -853,10 +854,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": "Отменить",
|
||||
@@ -873,7 +880,11 @@
|
||||
"renderBypassState": "Отображать состояние обхода",
|
||||
"renderErrorState": "Отображать состояние ошибки",
|
||||
"showGroups": "Показать фреймы/группы",
|
||||
"showLinks": "Показать связи"
|
||||
"showLinks": "Показать связи",
|
||||
"sideToolbar_modelLibrary": "sideToolbar.каталогМоделей",
|
||||
"sideToolbar_nodeLibrary": "sideToolbar.каталогУзлов",
|
||||
"sideToolbar_queue": "sideToolbar.очередь",
|
||||
"sideToolbar_workflows": "sideToolbar.рабочиеПроцессы"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "Больше не показывать это",
|
||||
@@ -1190,6 +1201,7 @@
|
||||
"UV": "UV",
|
||||
"User": "Пользователь",
|
||||
"Validation": "Валидация",
|
||||
"Vue Nodes": "Vue Nodes",
|
||||
"Window": "Окно",
|
||||
"Workflow": "Рабочий процесс"
|
||||
},
|
||||
@@ -1708,4 +1720,4 @@
|
||||
"showMinimap": "Показать миникарту",
|
||||
"zoomToFit": "Масштабировать по размеру"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "停用中",
|
||||
"dismiss": "關閉",
|
||||
"download": "下載",
|
||||
"dropYourFileOr": "拖放您的檔案或",
|
||||
"duplicate": "複製",
|
||||
"edit": "編輯",
|
||||
"empty": "空",
|
||||
@@ -857,6 +858,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": "復原",
|
||||
@@ -1190,6 +1197,7 @@
|
||||
"UV": "UV",
|
||||
"User": "使用者",
|
||||
"Validation": "驗證",
|
||||
"Vue Nodes": "Vue 節點",
|
||||
"Window": "視窗",
|
||||
"Workflow": "工作流程"
|
||||
},
|
||||
@@ -1708,4 +1716,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": {
|
||||
|
||||
@@ -261,13 +261,13 @@
|
||||
"label": "切换日志底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
|
||||
"label": "切换基础底部面板"
|
||||
"label": "切換基本下方面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
|
||||
"label": "切换视图控制底部面板"
|
||||
"label": "切換檢視控制底部面板"
|
||||
},
|
||||
"Workspace_ToggleBottomPanel_Shortcuts": {
|
||||
"label": "显示快捷键对话框"
|
||||
"label": "顯示快捷鍵對話框"
|
||||
},
|
||||
"Workspace_ToggleFocusMode": {
|
||||
"label": "切换焦点模式"
|
||||
|
||||
@@ -300,6 +300,7 @@
|
||||
"disabling": "禁用中",
|
||||
"dismiss": "关闭",
|
||||
"download": "下载",
|
||||
"dropYourFileOr": "拖放您的文件或",
|
||||
"duplicate": "复制",
|
||||
"edit": "编辑",
|
||||
"empty": "空",
|
||||
@@ -754,7 +755,7 @@
|
||||
"instantTooltip": "工作流将会在生成完成后立即执行",
|
||||
"interrupt": "取消当前任务",
|
||||
"light": "淺色",
|
||||
"manageExtensions": "管理擴充功能",
|
||||
"manageExtensions": "管理扩展功能",
|
||||
"onChange": "更改时",
|
||||
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
|
||||
"queue": "队列面板",
|
||||
@@ -852,11 +853,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": "撤销",
|
||||
@@ -873,7 +877,11 @@
|
||||
"renderBypassState": "渲染绕过状态",
|
||||
"renderErrorState": "渲染错误状态",
|
||||
"showGroups": "显示框架/分组",
|
||||
"showLinks": "显示连接"
|
||||
"showLinks": "显示连接",
|
||||
"sideToolbar_modelLibrary": "侧边工具栏.模型库",
|
||||
"sideToolbar_nodeLibrary": "侧边工具栏.节点库",
|
||||
"sideToolbar_queue": "侧边工具栏.队列",
|
||||
"sideToolbar_workflows": "侧边工具栏.工作流"
|
||||
},
|
||||
"missingModelsDialog": {
|
||||
"doNotAskAgain": "不再显示此消息",
|
||||
@@ -1190,6 +1198,7 @@
|
||||
"UV": "UV",
|
||||
"User": "用户",
|
||||
"Validation": "验证",
|
||||
"Vue Nodes": "Vue 节点",
|
||||
"Window": "窗口",
|
||||
"Workflow": "工作流"
|
||||
},
|
||||
@@ -1708,4 +1717,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": {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
type ArrowShape,
|
||||
CanvasPathRenderer,
|
||||
type Direction,
|
||||
type DragLinkData,
|
||||
type LinkRenderData,
|
||||
type RenderContext as PathRenderContext,
|
||||
type Point,
|
||||
type RenderMode
|
||||
} from '@/renderer/core/canvas/PathRenderer'
|
||||
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/SlotCalculations'
|
||||
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]
|
||||
}
|
||||
62
src/renderer/core/layout/constants.ts
Normal file
62
src/renderer/core/layout/constants.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Default values for node layout
|
||||
*/
|
||||
export const NODE_DEFAULTS = {
|
||||
/** Default node size when not specified */
|
||||
SIZE: { width: 200, height: 100 },
|
||||
/** Default z-index for new nodes */
|
||||
Z_INDEX: 0,
|
||||
/** Default visibility state */
|
||||
VISIBLE: true
|
||||
} 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
|
||||
276
src/renderer/core/layout/operations/LayoutMutations.ts
Normal file
276
src/renderer/core/layout/operations/LayoutMutations.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* 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 { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
|
||||
import {
|
||||
type LayoutMutations,
|
||||
LayoutSource,
|
||||
type NodeId,
|
||||
type NodeLayout,
|
||||
type Point,
|
||||
type Size
|
||||
} from '@/renderer/core/layout/types'
|
||||
|
||||
const logger = log.getLogger('LayoutMutations')
|
||||
|
||||
class LayoutMutationsImpl implements LayoutMutations {
|
||||
/**
|
||||
* Set the current mutation source
|
||||
*/
|
||||
setSource(source: LayoutSource): void {
|
||||
layoutStore.setSource(source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current actor (for CRDT)
|
||||
*/
|
||||
setActor(actor: string): void {
|
||||
layoutStore.setActor(actor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a node to a new position
|
||||
*/
|
||||
moveNode(nodeId: NodeId, position: Point): void {
|
||||
const existing = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
position,
|
||||
previousPosition: existing.position,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a node
|
||||
*/
|
||||
resizeNode(nodeId: NodeId, size: Size): void {
|
||||
const existing = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'resizeNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
size,
|
||||
previousSize: existing.size,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set node z-index
|
||||
*/
|
||||
setNodeZIndex(nodeId: NodeId, zIndex: number): void {
|
||||
const existing = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'setNodeZIndex',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
zIndex,
|
||||
previousZIndex: existing.zIndex,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node
|
||||
*/
|
||||
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void {
|
||||
const fullLayout: NodeLayout = {
|
||||
id: nodeId,
|
||||
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,
|
||||
layout: fullLayout,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a node
|
||||
*/
|
||||
deleteNode(nodeId: NodeId): void {
|
||||
const existing = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
if (!existing) return
|
||||
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteNode',
|
||||
entity: 'node',
|
||||
nodeId,
|
||||
previousLayout: existing,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring a node to the front (highest z-index)
|
||||
*/
|
||||
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
|
||||
this.setNodeZIndex(nodeId, maxZIndex + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new link
|
||||
*/
|
||||
createLink(
|
||||
linkId: string | number,
|
||||
sourceNodeId: string | number,
|
||||
sourceSlot: number,
|
||||
targetNodeId: string | number,
|
||||
targetSlot: number
|
||||
): void {
|
||||
// Normalize node IDs to strings
|
||||
const normalizedSourceNodeId = String(sourceNodeId)
|
||||
const normalizedTargetNodeId = String(targetNodeId)
|
||||
|
||||
logger.debug('Creating link:', {
|
||||
linkId: Number(linkId),
|
||||
from: `${normalizedSourceNodeId}[${sourceSlot}]`,
|
||||
to: `${normalizedTargetNodeId}[${targetSlot}]`
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'createLink',
|
||||
entity: 'link',
|
||||
linkId: Number(linkId),
|
||||
sourceNodeId: normalizedSourceNodeId,
|
||||
sourceSlot,
|
||||
targetNodeId: normalizedTargetNodeId,
|
||||
targetSlot,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a link
|
||||
*/
|
||||
deleteLink(linkId: string | number): void {
|
||||
logger.debug('Deleting link:', Number(linkId))
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteLink',
|
||||
entity: 'link',
|
||||
linkId: Number(linkId),
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new reroute
|
||||
*/
|
||||
createReroute(
|
||||
rerouteId: string | number,
|
||||
position: Point,
|
||||
parentId?: string | number,
|
||||
linkIds: (string | number)[] = []
|
||||
): void {
|
||||
logger.debug('Creating reroute:', {
|
||||
rerouteId: Number(rerouteId),
|
||||
position,
|
||||
parentId: parentId != null ? Number(parentId) : undefined,
|
||||
linkCount: linkIds.length
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'createReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId: Number(rerouteId),
|
||||
position,
|
||||
parentId: parentId != null ? Number(parentId) : undefined,
|
||||
linkIds: linkIds.map((id) => Number(id)),
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a reroute
|
||||
*/
|
||||
deleteReroute(rerouteId: string | number): void {
|
||||
logger.debug('Deleting reroute:', Number(rerouteId))
|
||||
layoutStore.applyOperation({
|
||||
type: 'deleteReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId: Number(rerouteId),
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a reroute
|
||||
*/
|
||||
moveReroute(
|
||||
rerouteId: string | number,
|
||||
position: Point,
|
||||
previousPosition: Point
|
||||
): void {
|
||||
logger.debug('Moving reroute:', {
|
||||
rerouteId: Number(rerouteId),
|
||||
from: previousPosition,
|
||||
to: position
|
||||
})
|
||||
layoutStore.applyOperation({
|
||||
type: 'moveReroute',
|
||||
entity: 'reroute',
|
||||
rerouteId: Number(rerouteId),
|
||||
position,
|
||||
previousPosition,
|
||||
timestamp: Date.now(),
|
||||
source: layoutStore.getCurrentSource(),
|
||||
actor: layoutStore.getCurrentActor()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const layoutMutations = new LayoutMutationsImpl()
|
||||
76
src/renderer/core/layout/slots/SlotIdentifier.ts
Normal file
76
src/renderer/core/layout/slots/SlotIdentifier.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a slot key back into its components
|
||||
*/
|
||||
export function parseSlotKey(key: string): SlotIdentifier | null {
|
||||
const match = key.match(/^(.+)-(in|out)-(\d+)$/)
|
||||
if (!match) return null
|
||||
|
||||
return {
|
||||
nodeId: match[1],
|
||||
isInput: match[2] === 'in',
|
||||
index: parseInt(match[3], 10)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key represents an input slot
|
||||
*/
|
||||
export function isInputSlotKey(key: string): boolean {
|
||||
return key.includes('-in-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key represents an output slot
|
||||
*/
|
||||
export function isOutputSlotKey(key: string): boolean {
|
||||
return key.includes('-out-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the node ID from a slot key
|
||||
*/
|
||||
export function getNodeIdFromSlotKey(key: string): string | null {
|
||||
const parsed = parseSlotKey(key)
|
||||
return parsed?.nodeId ?? null
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
228
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
228
src/renderer/core/layout/slots/useDomSlotRegistration.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
>()
|
||||
|
||||
export function useDomSlotRegistration(
|
||||
nodeId: string,
|
||||
slotIndex: number,
|
||||
isInput: boolean,
|
||||
transform?: TransformState
|
||||
) {
|
||||
// Early return if no nodeId
|
||||
if (!nodeId || nodeId === '') {
|
||||
return {
|
||||
slotElRef: ref<HTMLElement | null>(null),
|
||||
remeasure: () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const elRef = ref<HTMLElement | null>(null)
|
||||
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 {
|
||||
slotElRef: elRef,
|
||||
// Expose for forced remeasure on structural changes
|
||||
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
|
||||
}
|
||||
}
|
||||
1233
src/renderer/core/layout/store/LayoutStore.ts
Normal file
1233
src/renderer/core/layout/store/LayoutStore.ts
Normal file
File diff suppressed because it is too large
Load Diff
31
src/renderer/core/layout/sync/useLayout.ts
Normal file
31
src/renderer/core/layout/sync/useLayout.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Main composable for accessing the layout system
|
||||
*
|
||||
* Provides unified access to the layout store and mutation API.
|
||||
*/
|
||||
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
|
||||
import type { Bounds, NodeId, Point } from '@/renderer/core/layout/types'
|
||||
|
||||
/**
|
||||
* Main composable for accessing the layout system
|
||||
*/
|
||||
export function useLayout() {
|
||||
return {
|
||||
// Store access
|
||||
store: layoutStore,
|
||||
|
||||
// Mutation API
|
||||
mutations: layoutMutations,
|
||||
|
||||
// Reactive accessors
|
||||
getNodeLayoutRef: (nodeId: NodeId) => layoutStore.getNodeLayoutRef(nodeId),
|
||||
getAllNodes: () => layoutStore.getAllNodes(),
|
||||
getNodesInBounds: (bounds: Bounds) => layoutStore.getNodesInBounds(bounds),
|
||||
|
||||
// Non-reactive queries (for performance)
|
||||
queryNodeAtPoint: (point: Point) => layoutStore.queryNodeAtPoint(point),
|
||||
queryNodesInBounds: (bounds: Bounds) =>
|
||||
layoutStore.queryNodesInBounds(bounds)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
163
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
163
src/renderer/core/layout/sync/useSlotLayoutSync.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Composable for managing slot layout registration
|
||||
*
|
||||
* Implements event-driven slot registration decoupled from the draw cycle.
|
||||
* Registers slots once on initial load and keeps them updated when necessary.
|
||||
*/
|
||||
import { onUnmounted } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { type SlotPositionContext } from '@/renderer/core/canvas/litegraph/SlotCalculations'
|
||||
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/LayoutStore'
|
||||
|
||||
/**
|
||||
* Compute and register slot layouts for a node
|
||||
* @param node LiteGraph node to process
|
||||
*/
|
||||
function computeAndRegisterSlots(node: LGraphNode): void {
|
||||
const nodeId = String(node.id)
|
||||
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
|
||||
|
||||
// Fallback to live node values if layout not ready
|
||||
const nodeX = nodeLayout?.position.x ?? node.pos[0]
|
||||
const nodeY = nodeLayout?.position.y ?? node.pos[1]
|
||||
const nodeWidth = nodeLayout?.size.width ?? node.size[0]
|
||||
const nodeHeight = nodeLayout?.size.height ?? node.size[1]
|
||||
|
||||
// Ensure concrete slots & arrange when needed for accurate positions
|
||||
node._setConcreteSlots()
|
||||
const collapsed = node.flags.collapsed ?? false
|
||||
if (!collapsed) {
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
const context: SlotPositionContext = {
|
||||
nodeX,
|
||||
nodeY,
|
||||
nodeWidth,
|
||||
nodeHeight,
|
||||
collapsed,
|
||||
collapsedWidth: node._collapsed_width,
|
||||
slotStartY: node.constructor.slot_start_y,
|
||||
inputs: node.inputs,
|
||||
outputs: node.outputs,
|
||||
widgets: node.widgets
|
||||
}
|
||||
|
||||
registerNodeSlots(nodeId, context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for managing slot layout registration
|
||||
*/
|
||||
export function useSlotLayoutSync() {
|
||||
let unsubscribeLayoutChange: (() => void) | null = null
|
||||
let restoreHandlers: (() => void) | null = null
|
||||
|
||||
/**
|
||||
* Start slot layout sync with full event-driven functionality
|
||||
* @param canvas LiteGraph canvas instance
|
||||
*/
|
||||
function start(canvas: LGraphCanvas): void {
|
||||
// When Vue nodes are enabled, slot DOM registers exact positions.
|
||||
// Skip calculated registration to avoid conflicts.
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
return
|
||||
}
|
||||
const graph = canvas?.graph
|
||||
if (!graph) return
|
||||
|
||||
// Initial registration for all nodes in the current graph
|
||||
for (const node of graph._nodes) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
|
||||
// Layout changes → recompute slots for changed nodes
|
||||
unsubscribeLayoutChange = layoutStore.onChange((change) => {
|
||||
for (const nodeId of change.nodeIds) {
|
||||
const node = graph.getNodeById(parseInt(nodeId))
|
||||
if (node) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// LiteGraph event hooks
|
||||
const origNodeAdded = graph.onNodeAdded
|
||||
const origNodeRemoved = graph.onNodeRemoved
|
||||
const origTrigger = graph.onTrigger
|
||||
const origAfterChange = graph.onAfterChange
|
||||
|
||||
graph.onNodeAdded = (node: LGraphNode) => {
|
||||
computeAndRegisterSlots(node)
|
||||
if (origNodeAdded) {
|
||||
origNodeAdded.call(graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onNodeRemoved = (node: LGraphNode) => {
|
||||
layoutStore.deleteNodeSlotLayouts(String(node.id))
|
||||
if (origNodeRemoved) {
|
||||
origNodeRemoved.call(graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onTrigger = (action: string, param: any) => {
|
||||
if (
|
||||
action === 'node:property:changed' &&
|
||||
param?.property === 'flags.collapsed'
|
||||
) {
|
||||
const node = graph.getNodeById(parseInt(String(param.nodeId)))
|
||||
if (node) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
}
|
||||
if (origTrigger) {
|
||||
origTrigger.call(graph, action, param)
|
||||
}
|
||||
}
|
||||
|
||||
graph.onAfterChange = (graph: any, node?: any) => {
|
||||
if (node && node.id) {
|
||||
computeAndRegisterSlots(node)
|
||||
}
|
||||
if (origAfterChange) {
|
||||
origAfterChange.call(graph, graph, node)
|
||||
}
|
||||
}
|
||||
|
||||
// Store cleanup function
|
||||
restoreHandlers = () => {
|
||||
graph.onNodeAdded = origNodeAdded || undefined
|
||||
graph.onNodeRemoved = origNodeRemoved || undefined
|
||||
graph.onTrigger = origTrigger || undefined
|
||||
graph.onAfterChange = origAfterChange || undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop slot layout sync and cleanup all subscriptions
|
||||
*/
|
||||
function stop(): void {
|
||||
if (unsubscribeLayoutChange) {
|
||||
unsubscribeLayoutChange()
|
||||
unsubscribeLayoutChange = null
|
||||
}
|
||||
if (restoreHandlers) {
|
||||
restoreHandlers()
|
||||
restoreHandlers = null
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
return {
|
||||
start,
|
||||
stop
|
||||
}
|
||||
}
|
||||
520
src/renderer/core/layout/types.ts
Normal file
520
src/renderer/core/layout/types.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// ID types for type safety
|
||||
export type NodeId = string
|
||||
export type SlotId = string
|
||||
export type ConnectionId = string
|
||||
export type LinkId = number // Aligned with Litegraph's numeric LinkId
|
||||
export type RerouteId = number // Aligned with Litegraph's numeric RerouteId
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
export interface ConnectionLayout {
|
||||
id: ConnectionId
|
||||
sourceSlot: SlotId
|
||||
targetSlot: SlotId
|
||||
// Control points for curved connections
|
||||
controlPoints?: Point[]
|
||||
}
|
||||
|
||||
// CRDT Operation Types
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
// Legacy alias for compatibility
|
||||
export type AnyLayoutOperation = LayoutOperation
|
||||
|
||||
/**
|
||||
* Type guards for operations
|
||||
*/
|
||||
export const isOperationMeta = (op: unknown): op is OperationMeta => {
|
||||
return (
|
||||
typeof op === 'object' &&
|
||||
op !== null &&
|
||||
'timestamp' in op &&
|
||||
'actor' in op &&
|
||||
'source' in op &&
|
||||
'type' in op
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Entity-specific helper functions
|
||||
*/
|
||||
export const isNodeOperation = (op: LayoutOperation): boolean => {
|
||||
return 'entity' in op && (op as any).entity === 'node'
|
||||
}
|
||||
|
||||
export const isLinkOperation = (op: LayoutOperation): boolean => {
|
||||
return 'entity' in op && (op as any).entity === 'link'
|
||||
}
|
||||
|
||||
export const isRerouteOperation = (op: LayoutOperation): boolean => {
|
||||
return 'entity' in op && (op as any).entity === 'reroute'
|
||||
}
|
||||
|
||||
export const isMoveNodeOperation = (
|
||||
op: LayoutOperation
|
||||
): op is MoveNodeOperation => op.type === 'moveNode'
|
||||
|
||||
export const isResizeNodeOperation = (
|
||||
op: LayoutOperation
|
||||
): op is ResizeNodeOperation => op.type === 'resizeNode'
|
||||
|
||||
export const isCreateNodeOperation = (
|
||||
op: LayoutOperation
|
||||
): op is CreateNodeOperation => op.type === 'createNode'
|
||||
|
||||
export const isDeleteNodeOperation = (
|
||||
op: LayoutOperation
|
||||
): op is DeleteNodeOperation => op.type === 'deleteNode'
|
||||
|
||||
export const isSetNodeVisibilityOperation = (
|
||||
op: LayoutOperation
|
||||
): op is SetNodeVisibilityOperation => op.type === 'setNodeVisibility'
|
||||
|
||||
export const isBatchUpdateOperation = (
|
||||
op: LayoutOperation
|
||||
): op is BatchUpdateOperation => op.type === 'batchUpdate'
|
||||
|
||||
export const isCreateLinkOperation = (
|
||||
op: LayoutOperation
|
||||
): op is CreateLinkOperation => op.type === 'createLink'
|
||||
|
||||
export const isDeleteLinkOperation = (
|
||||
op: LayoutOperation
|
||||
): op is DeleteLinkOperation => op.type === 'deleteLink'
|
||||
|
||||
export const isCreateRerouteOperation = (
|
||||
op: LayoutOperation
|
||||
): op is CreateRerouteOperation => op.type === 'createReroute'
|
||||
|
||||
export const isDeleteRerouteOperation = (
|
||||
op: LayoutOperation
|
||||
): op is DeleteRerouteOperation => op.type === 'deleteReroute'
|
||||
|
||||
export const isMoveRerouteOperation = (
|
||||
op: LayoutOperation
|
||||
): op is MoveRerouteOperation => op.type === 'moveReroute'
|
||||
|
||||
/**
|
||||
* Helper function to get affected node IDs from any operation
|
||||
* Useful for change notifications and cache invalidation
|
||||
*/
|
||||
export const getAffectedNodeIds = (op: LayoutOperation): NodeId[] => {
|
||||
switch (op.type) {
|
||||
case 'moveNode':
|
||||
case 'resizeNode':
|
||||
case 'setNodeZIndex':
|
||||
case 'createNode':
|
||||
case 'deleteNode':
|
||||
case 'setNodeVisibility':
|
||||
case 'batchUpdate':
|
||||
return [(op as NodeOpBase).nodeId]
|
||||
case 'createLink': {
|
||||
const createLink = op as CreateLinkOperation
|
||||
return [createLink.sourceNodeId, createLink.targetNodeId]
|
||||
}
|
||||
case 'deleteLink':
|
||||
// Link deletion doesn't directly affect nodes
|
||||
return []
|
||||
case 'createReroute':
|
||||
case 'deleteReroute':
|
||||
case 'moveReroute':
|
||||
// Reroute operations don't directly affect nodes
|
||||
return []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation application interface
|
||||
*/
|
||||
export interface OperationApplicator<
|
||||
T extends LayoutOperation = LayoutOperation
|
||||
> {
|
||||
canApply(operation: T): boolean
|
||||
apply(operation: T): void
|
||||
reverse(operation: T): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation serialization for network/storage
|
||||
*/
|
||||
export interface OperationSerializer {
|
||||
serialize(operation: LayoutOperation): string
|
||||
deserialize(data: string): LayoutOperation
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict resolution strategy
|
||||
*/
|
||||
export interface ConflictResolver {
|
||||
resolve(op1: LayoutOperation, op2: LayoutOperation): LayoutOperation[]
|
||||
}
|
||||
|
||||
// Change notification types
|
||||
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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Simplified mutation API
|
||||
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: string | number,
|
||||
sourceNodeId: string | number,
|
||||
sourceSlot: number,
|
||||
targetNodeId: string | number,
|
||||
targetSlot: number
|
||||
): void
|
||||
deleteLink(linkId: string | number): void
|
||||
|
||||
// Reroute operations
|
||||
createReroute(
|
||||
rerouteId: string | number,
|
||||
position: Point,
|
||||
parentId?: string | number,
|
||||
linkIds?: (string | number)[]
|
||||
): void
|
||||
deleteReroute(rerouteId: string | number): void
|
||||
moveReroute(
|
||||
rerouteId: string | number,
|
||||
position: Point,
|
||||
previousPosition: Point
|
||||
): void
|
||||
|
||||
// Stacking operations
|
||||
bringNodeToFront(nodeId: NodeId): void
|
||||
|
||||
// Source tracking
|
||||
setSource(source: LayoutSource): void
|
||||
setActor(actor: string): void // For CRDT
|
||||
}
|
||||
|
||||
// CRDT-ready operation log (for future CRDT integration)
|
||||
export interface OperationLog {
|
||||
operations: LayoutOperation[]
|
||||
addOperation(operation: LayoutOperation): void
|
||||
getOperationsSince(timestamp: number): LayoutOperation[]
|
||||
getOperationsByActor(actor: string): LayoutOperation[]
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
106
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
106
src/renderer/extensions/vueNodes/components/InputSlot.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--input flex items-center cursor-crosshair group"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible,
|
||||
'lg-slot--dot-only': dotOnly,
|
||||
'pr-2 hover:bg-black/5': !dotOnly
|
||||
}"
|
||||
:style="{
|
||||
height: slotHeight + 'px'
|
||||
}"
|
||||
@pointerdown="handleClick"
|
||||
>
|
||||
<!-- Connection Dot -->
|
||||
<div class="w-5 h-5 flex items-center justify-center group/slot">
|
||||
<div
|
||||
ref="slotElRef"
|
||||
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
|
||||
:style="{
|
||||
backgroundColor: slotColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
|
||||
>
|
||||
{{ slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, onErrorCaptured, ref } 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'
|
||||
|
||||
interface InputSlotProps {
|
||||
node?: LGraphNode
|
||||
nodeId?: string
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
dotOnly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
// 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
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (event: PointerEvent) => {
|
||||
if (!props.readonly) {
|
||||
emit('slot-click', event)
|
||||
}
|
||||
}
|
||||
|
||||
const transformState = inject<TransformState | undefined>(
|
||||
'transformState',
|
||||
undefined
|
||||
)
|
||||
|
||||
const { slotElRef } = useDomSlotRegistration(
|
||||
props.nodeId ?? '',
|
||||
props.index,
|
||||
true,
|
||||
transformState
|
||||
)
|
||||
</script>
|
||||
272
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
272
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ 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-2 border-solid rounded-2xl',
|
||||
{
|
||||
'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,
|
||||
'hover:border-green-500'
|
||||
)
|
||||
"
|
||||
: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">
|
||||
<MultiSlotPoint class="absolute left-0 -translate-x-1/2" />
|
||||
<MultiSlotPoint 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 MultiSlotPoint from './MultiSlotPoint.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.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-4'
|
||||
|
||||
// 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>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
color?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:style="{ backgroundColor: color }"
|
||||
class="bg-[#5B5E7D] w-[13.3px] h-[29.3px] rounded-full"
|
||||
/>
|
||||
</template>
|
||||
41
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
41
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ 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>
|
||||
116
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
116
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div v-if="renderError" class="node-error p-4 text-red-500 text-sm">
|
||||
⚠️ 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>
|
||||
139
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
139
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Slots Error
|
||||
</div>
|
||||
<div v-else class="lg-node-slots flex justify-between">
|
||||
<div v-if="filteredInputs.length" class="flex flex-col">
|
||||
<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"
|
||||
@slot-click="
|
||||
handleInputSlotClick(getActualInputIndex(input, index), $event)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredOutputs.length" class="flex flex-col 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"
|
||||
@slot-click="handleOutputSlotClick(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onErrorCaptured, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { useEventForwarding } from '@/composables/graph/useEventForwarding'
|
||||
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
|
||||
}
|
||||
|
||||
// Set up event forwarding for slot interactions
|
||||
const { handleSlotPointerDown, cleanup } = useEventForwarding()
|
||||
|
||||
// Handle input slot click
|
||||
const handleInputSlotClick = (_index: number, event: PointerEvent) => {
|
||||
// Forward the event to LiteGraph for native slot handling
|
||||
handleSlotPointerDown(event)
|
||||
}
|
||||
|
||||
// Handle output slot click
|
||||
const handleOutputSlotClick = (_index: number, event: PointerEvent) => {
|
||||
// Forward the event to LiteGraph for native slot handling
|
||||
handleSlotPointerDown(event)
|
||||
}
|
||||
|
||||
// Clean up event listeners on unmount
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
toastErrorHandler(error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user