Compare commits

...

36 Commits

Author SHA1 Message Date
Rizumu Ayaka
edebfbff98 chore: update test widgets node 2025-09-02 22:53:36 +08:00
Rizumu Ayaka
60ce863b60 feat(V3 UI style): color picker + file upload + input text + multi select + select + select button + slider + textarea + tree select 2025-09-02 22:47:07 +08:00
Rizumu Ayaka
242a4a4454 feat: the selection style of LGraphNode 2025-09-02 14:55:00 +08:00
Rizumu Ayaka
1ddb453522 feat: widget select button style 2025-09-02 14:53:18 +08:00
Rizumu Ayaka
962302d5ba feat: widget input text style 2025-09-02 14:48:40 +08:00
Rizumu Ayaka
fc7365ba49 fix: the version of tailwind-merge does not match 2025-09-02 14:35:28 +08:00
Rizumu Ayaka
5f8e5365ed wip: create a test widget to test 2025-08-29 02:14:26 +08:00
Rizumu Ayaka
d9efa78723 feat: localization fields 2025-08-28 16:27:58 +08:00
Rizumu Ayaka
30e7e0956e refactor: v3 ui slots connection dots 2025-08-27 18:23:28 +08:00
Rizumu Ayaka
3895a43b73 feat: v3 style of node body (#5169)
* feat: v3 style of node body

* Update src/renderer/extensions/vueNodes/components/LGraphNode.vue

* fix: review's issues

* fix: review's issue
2025-08-22 21:49:13 -07:00
Benjamin Lu
907ba12e04 [bugfix] Hide center dot when dragging links (#5133)
The center dot/marker on links should not be visible when the user is dragging links to connect nodes. This fix ensures the center marker is hidden during link dragging operations.

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-20 20:41:29 -04:00
Benjamin Lu
4b9a3a1984 [bugfix] Fix link center dot hit detection when marker is disabled (#5135)
When linkMarkerShape is set to None, clicks were still being detected on the invisible center dot. This fix adds proper checks to skip hit detection when the center marker is disabled.

Fixes center dot hit detection issue

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-20 16:06:25 -07:00
Benjamin Lu
0939eba7d8 Revert "feat: Add slot registration and spatial indexing for hit detection"
This reverts commit 70fbfd0f5e.
2025-08-20 16:30:40 -04:00
Benjamin Lu
70fbfd0f5e feat: Add slot registration and spatial indexing for hit detection
- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-20 16:26:59 -04:00
Christian Byrne
3a52c46a04 remove logging from vue node layouting modules (#5111) 2025-08-19 15:02:32 -07:00
Christian Byrne
55c4e3418a Remove layout logging noise from console (#5101)
- Remove loglevel import and logger setup from LayoutStore
- Remove all logger.debug() calls throughout LayoutStore
- Remove localStorage debug check for layout operations
- Remove unused DEBUG_CONFIG from layout constants
- Clean up console noise while preserving error handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-19 10:05:29 -07:00
bymyself
23ceb04994 [bugfix] Fix Vue node import path after refactoring
Update LGraphNode import path from old location to new domain-driven architecture path.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 09:53:46 -07:00
Christian Byrne
8e8dd63696 fix: Initialize Vue node manager when first node is added to empty graph (#5086)
* fix: Initialize Vue node manager when first node is added to empty graph

When Vue nodes are enabled and the graph starts empty (0 nodes), the
node manager wasn't being initialized. This caused Vue nodes to not
render until the setting was toggled off and on again.

The fix adds a one-time event handler that listens for the first node
being added to an empty graph and initializes the node manager at that
point.

Fixes the issue where Vue nodes don't render on initial page load when
the setting is enabled.

* fix: Add TODO comment for reactive graph mutations observer

Added comment to indicate that the monkey-patching approach should be
replaced with a proper reactive graph mutations observer when available.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-19 09:52:01 -07:00
Christian Byrne
316e05b8b9 [refactor] Reorganize Vue nodes to domain-driven design architecture (#5085)
* refactor: Reorganize Vue nodes system to domain-driven design architecture

Move Vue nodes code from scattered technical layers to domain-focused structure:

- Widget system → src/renderer/extensions/vueNodes/widgets/
- LOD optimization → src/renderer/extensions/vueNodes/lod/
- Layout logic → src/renderer/extensions/vueNodes/layout/
- Node components → src/renderer/extensions/vueNodes/components/
- Test structure mirrors source organization

Benefits:
- Clear domain boundaries instead of technical layers
- Everything Vue nodes related in renderer domain (not workbench)
- camelCase naming (vueNodes vs vue-nodes)
- Tests co-located with source domains
- All imports updated to new DDD structure

* fix: Skip spatial index performance test on CI to avoid flaky timing

Performance tests are inherently flaky on CI due to variable system
performance. This test should only run locally like the other
performance tests.
2025-08-18 16:58:45 -07:00
Christian Byrne
0e236b8e2c refactor: Reorganize layout system into new renderer architecture (#5071)
- Move layout system to renderer/core/layout/
  - Store, operations, adapters, and sync modules organized clearly
  - Merged layoutTypes.ts and layoutOperations.ts into single types.ts
- Move canvas rendering to renderer/core/canvas/
  - LiteGraph-specific code in litegraph/ subdirectory
  - PathRenderer at canvas level
- Move spatial indexing to renderer/core/spatial/
- Move Vue node composables to renderer/extensions/vue-nodes/
- Update all import paths throughout codebase
- Apply consistent naming (renderer vs rendering)

This establishes clearer separation between core rendering concerns
and optional extensions, making the architecture more maintainable.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-08-17 20:19:00 -07:00
Benjamin Lu
d02cd527ae [chore] Extract link rendering out of LGraphCanvas (#4994)
* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Remove unused methods in LGLA

* Extract slot position calculations to shared utility

- Create slotCalculations.ts utility for centralized slot position logic
- Update LGraphNode to delegate to helper while maintaining compatibility
- Modify LitegraphLinkAdapter to use layout tree positions when available
- Enable link rendering to use layout system coordinates instead of litegraph positions

This allows the layout tree to control link rendering positions, enabling proper
synchronization between Vue components and canvas rendering.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Restore original link rendering behavior after refactor

This commit fixes several rendering discrepancies introduced during the link rendering refactor to ensure exact parity with the original litegraph implementation:

Path Shape Fixes:
- STRAIGHT_LINK: Now correctly applies l=10 offset to create innerA/innerB points and uses midX=(innerA.x+innerB.x)*0.5 for elbow placement, matching the original 6-segment path
- LINEAR_LINK: Restored 4-point path with l=15 directional offsets (start → innerA → innerB → end)

Arrow Rendering:
- computeConnectionPoint: Now always uses bezier math with 0.25 factor spline offsets regardless of render mode, ensuring arrow positions match original
- Arrow positions: Fixed to render at 0.25 and 0.75 positions along the path
- Arrow gating: Moved scale>=0.6 and highQuality checks to adapter layer to maintain PathRenderer purity
- Arrow shape: Restored original triangle dimensions (-5,-3) to (0,+7) to (+5,-3)

Center Marker:
- Fixed 'None' option: Center marker now correctly hidden when LinkMarkerShape.None is selected
- Center point calculation: Updated for all render modes to match original positions
- STRAIGHT_LINK center: Uses midX and average of innerA/innerB y-coordinates
- LINEAR_LINK center: Uses midpoint between innerA and innerB control points

These fixes ensure backward compatibility while maintaining the clean separation between the pure PathRenderer and litegraph-specific LitegraphLinkAdapter.

Fixes #Issue-Number

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
2025-08-17 18:07:23 -07:00
Christian Byrne
4f337be837 feat: Implement CRDT-based layout system for Vue nodes (#4959)
* feat: Implement CRDT-based layout system for Vue nodes

Major refactor to solve snap-back issues and create single source of truth for node positions:

- Add Yjs-based CRDT layout store for conflict-free position management
- Implement layout mutations service with clean API
- Create Vue composables for layout access and node dragging
- Add one-way sync from layout store to LiteGraph
- Disable LiteGraph dragging when Vue nodes mode is enabled
- Add z-index management with bring-to-front on node interaction
- Add comprehensive TypeScript types for layout system
- Include unit tests for layout store operations
- Update documentation to reflect CRDT architecture

This provides a solid foundation for both single-user performance and future real-time collaboration features.

Co-Authored-By: Claude <noreply@anthropic.com>

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

- Remove unnecessary types/README.md file
- Revert unrelated changes to services/README.md
- Keep only relevant documentation for the layout system implementation

These were issues identified during PR review that needed to be addressed.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Clean up layout store and implement proper CRDT operations

- Created dedicated layoutOperations.ts with production-grade CRDT interfaces
- Integrated existing QuadTree spatial index instead of simple cache
- Split composables into separate files (useLayout, useNodeLayout, useLayoutSync)
- Cleaned up operation handlers using specific types instead of Extract
- Added proper operation interfaces with type guards and extensibility
- Updated all type references to use new operation structure

The layout store now properly uses the existing QuadTree infrastructure for
efficient spatial queries and follows CRDT best practices with well-defined
operation interfaces.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Extract services and split composables for better organization

- Created SpatialIndexManager to handle QuadTree operations separately
- Added LayoutAdapter interface for CRDT abstraction (Yjs, mock implementations)
- Split GraphNodeManager into focused composables:
  - useNodeWidgets: Widget state and callback management
  - useNodeChangeDetection: RAF-based geometry change detection
  - useNodeState: Node visibility and reactive state management
- Extracted constants for magic numbers and configuration values
- Updated layout store to use SpatialIndexManager and constants

This improves code organization, testability, and makes it easier to swap
CRDT implementations or mock services for testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

- Add ADR-0002 for CRDT-based layout system decision
- Follow established ADR template with persuasive reasoning
- Include performance benefits, collaboration readiness, and architectural advantages
- Update ADR index

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
2025-08-16 20:49:17 -07:00
Benjamin Lu
88a5fb0a44 Add vue node feature flag (#4927) 2025-08-12 15:59:24 -04:00
Benjamin Lu
7a8f539468 test(ci): skip transformPerformance suite on CI (#4843) 2025-08-08 18:15:18 -07:00
Benjamin Lu
2c121a4a3c Revert "refactor(litegraph): decouple render-time state from models for reroutes and links\n\nIntroduce RenderedLinkSegment; compute reroute render params without mutating model; render into ephemeral segments instead of writing to Reroute/LLink."
This reverts commit d7ed1d36ed.
2025-08-08 16:34:32 -04:00
Benjamin Lu
d7ed1d36ed refactor(litegraph): decouple render-time state from models for reroutes and links\n\nIntroduce RenderedLinkSegment; compute reroute render params without mutating model; render into ephemeral segments instead of writing to Reroute/LLink. 2025-08-08 16:29:09 -04:00
github-actions
3fd0a8b125 Update locales [skip ci] 2025-08-08 18:03:41 +00:00
Christian Byrne
0a7431edd5 Fix TransformPane pos/size (#4826) 2025-08-08 10:58:54 -07:00
github-actions
7c7263f2cd Update locales [skip ci] 2025-08-07 09:36:14 +00:00
Christian Byrne
2ab4fb79ee [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2025-08-07 05:31:20 -04:00
bymyself
d488e59a2a [feat] Add Vue action widgets
- WidgetButton: Action button with Button component and callback handling
- WidgetFileUpload: File upload interface with FileUpload component
2025-08-06 18:47:18 -04:00
bymyself
dfd6c46764 [feat] Add Vue visual widgets
- WidgetColorPicker: Color selection with ColorPicker component
- WidgetImage: Single image display with Image component
- WidgetImageCompare: Before/after comparison with ImageCompare component
- WidgetGalleria: Image gallery/carousel with Galleria component
- WidgetChart: Data visualization with Chart component
2025-08-06 18:47:18 -04:00
bymyself
199e256824 [feat] Add Vue selection widgets
- WidgetSelect: Dropdown selection with Select component
- WidgetMultiSelect: Multiple selection with MultiSelect component
- WidgetSelectButton: Button group selection with SelectButton component
- WidgetTreeSelect: Hierarchical selection with TreeSelect component
2025-08-06 18:47:18 -04:00
bymyself
2f3b0d8db4 [feat] Add Vue input widgets
- WidgetInputText: Single-line text input with InputText component
- WidgetTextarea: Multi-line text input with Textarea component
- WidgetSlider: Numeric range input with Slider component
- WidgetToggleSwitch: Boolean toggle with ToggleSwitch component
2025-08-06 18:47:18 -04:00
bymyself
b0867c463b [feat] Add Vue widget registry system
- Complete widget type enum with all 15 widget types
- Component mapping registry for dynamic widget rendering
- Helper function for type-safe widget component resolution
2025-08-06 18:47:18 -04:00
bymyself
e6c33e1eb8 [feat] Add core Vue widget infrastructure
- SimplifiedWidget interface for Vue-based node widgets
- widgetPropFilter utility with component-specific exclusion lists
- Removes DOM manipulation and positioning concerns
- Provides clean API for value binding and prop filtering
2025-08-06 18:47:18 -04:00
218 changed files with 23338 additions and 500 deletions

View File

@@ -59,3 +59,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 })" />`

View 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()
}
}

View 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')
})
})

View 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/)

View File

@@ -11,6 +11,7 @@ An Architecture Decision Record captures an important architectural decision mad
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-crdt-based-layout-system.md) | CRDT-Based Layout System | Accepted | 2024-08-16 |
## Creating a New ADR

86
package-lock.json generated
View File

@@ -28,6 +28,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",
"extendable-media-recorder": "^9.2.27",
@@ -42,12 +44,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"
},
@@ -2745,6 +2749,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@langchain/core": {
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@langchain/core/-/core-0.2.36.tgz",
@@ -6321,6 +6331,18 @@
"node": "*"
}
},
"node_modules/chart.js": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
"integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/check-error": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
@@ -6515,6 +6537,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/code-excerpt": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz",
@@ -10084,6 +10115,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/jackspeak": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz",
@@ -10887,6 +10927,26 @@
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true
},
"node_modules/lib0": {
"version": "0.2.114",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.114.tgz",
"integrity": "sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
@@ -15181,6 +15241,16 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tailwind-merge": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz",
"integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz",
@@ -17898,6 +17968,22 @@
"node": ">=8"
}
},
"node_modules/yjs": {
"version": "13.6.27",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz",
"integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",

View File

@@ -94,6 +94,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",
"extendable-media-recorder": "^9.2.27",
@@ -108,12 +110,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"
}

View File

@@ -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 */
@@ -637,3 +875,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;
}

View File

@@ -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()
})
})

View File

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

View File

@@ -34,6 +34,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 />
@@ -44,13 +93,23 @@
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<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, 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'
@@ -61,14 +120,23 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.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 { useMinimap } from '@/composables/useMinimap'
@@ -77,7 +145,11 @@ 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 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'
@@ -105,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'
)
@@ -123,6 +196,357 @@ const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
// 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
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)
// Initialize layout sync (one-way: Layout Store → LiteGraph)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
const disposeNodeManager = () => {
if (!nodeManager) return
try {
cleanupNodeManager?.()
} catch {
/* empty */
}
nodeManager = null
cleanupNodeManager = 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 {
disposeNodeManager()
}
},
{ 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('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')
})
@@ -274,7 +698,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
}
}
@@ -291,6 +715,7 @@ onMounted(async () => {
useCopy()
usePaste()
useWorkflowAutoSave()
useFeatureFlags() // This will automatically sync Vue nodes flag with LiteGraph
comfyApp.vueAppReady = true
@@ -303,9 +728,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()
@@ -330,6 +752,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()
@@ -377,4 +825,18 @@ 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
}
})
</script>

View 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')
})
})
})

View 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>

View File

@@ -0,0 +1,112 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div class="pt-2 border-t border-surface-200 dark-theme:border-surface-700">
<h4 class="font-semibold mb-1">QuadTree Spatial Index</h4>
<!-- Enable/Disable Toggle -->
<div class="mb-2">
<label class="flex items-center gap-2">
<input
:checked="enabled"
type="checkbox"
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
/>
<span>Enable Spatial Indexing</span>
</label>
</div>
<!-- Status Message -->
<p v-if="!enabled" class="text-muted text-xs italic">
{{ statusMessage }}
</p>
<!-- Metrics when enabled -->
<template v-if="enabled && metrics">
<p class="text-muted">Strategy: {{ strategy }}</p>
<p class="text-muted">Total Nodes: {{ metrics.totalNodes }}</p>
<p class="text-muted">Visible Nodes: {{ metrics.visibleNodes }}</p>
<p class="text-muted">Query Time: {{ metrics.queryTime.toFixed(2) }}ms</p>
<p class="text-muted">Tree Depth: {{ metrics.treeDepth }}</p>
<p class="text-muted">Culling Efficiency: {{ cullingEfficiency }}</p>
<p class="text-muted">Rebuilds: {{ metrics.rebuildCount }}</p>
<!-- Show debug visualization toggle -->
<div class="mt-2">
<label class="flex items-center gap-2">
<input
:checked="showVisualization"
type="checkbox"
@change="
$emit(
'toggle-visualization',
($event.target as HTMLInputElement).checked
)
"
/>
<span>Show QuadTree Boundaries</span>
</label>
</div>
</template>
<!-- Performance Comparison -->
<template v-if="enabled && performanceComparison">
<div class="mt-2 text-xs">
<p class="text-muted font-semibold">Performance vs Linear:</p>
<p class="text-muted">Speedup: {{ performanceComparison.speedup }}x</p>
<p class="text-muted">
Break-even: ~{{ performanceComparison.breakEvenNodeCount }} nodes
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
enabled: boolean
metrics?: {
totalNodes: number
visibleNodes: number
queryTime: number
treeDepth: number
rebuildCount: number
}
strategy?: string
threshold?: number
showVisualization?: boolean
performanceComparison?: {
speedup: number
breakEvenNodeCount: number
}
}
const props = withDefaults(defineProps<Props>(), {
metrics: undefined,
strategy: 'quadtree',
threshold: 100,
showVisualization: false,
performanceComparison: undefined
})
defineEmits<{
toggle: [enabled: boolean]
'toggle-visualization': [show: boolean]
}>()
const statusMessage = computed(() => {
if (!props.enabled && props.metrics) {
return `Disabled (threshold: ${props.threshold} nodes, current: ${props.metrics.totalNodes})`
}
return `Spatial indexing will enable at ${props.threshold}+ nodes`
})
const cullingEfficiency = computed(() => {
if (!props.metrics || props.metrics.totalNodes === 0) return 'N/A'
const culled = props.metrics.totalNodes - props.metrics.visibleNodes
const percentage = ((culled / props.metrics.totalNodes) * 100).toFixed(1)
return `${culled} nodes (${percentage}%)`
})
</script>

View File

@@ -0,0 +1,112 @@
<template>
<svg
v-if="visible && debugInfo"
:width="svgSize.width"
:height="svgSize.height"
:style="svgStyle"
class="quadtree-visualization"
>
<!-- QuadTree boundaries -->
<g v-for="(node, index) in flattenedNodes" :key="`quad-${index}`">
<rect
:x="node.bounds.x"
:y="node.bounds.y"
:width="node.bounds.width"
:height="node.bounds.height"
:stroke="getDepthColor(node.depth)"
:stroke-width="getStrokeWidth(node.depth)"
fill="none"
:opacity="0.3 + node.depth * 0.05"
/>
</g>
<!-- Viewport bounds (optional) -->
<rect
v-if="viewportBounds"
:x="viewportBounds.x"
:y="viewportBounds.y"
:width="viewportBounds.width"
:height="viewportBounds.height"
stroke="#00ff00"
stroke-width="3"
fill="none"
stroke-dasharray="10,5"
opacity="0.8"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Bounds } from '@/utils/spatial/QuadTree'
interface Props {
visible: boolean
debugInfo: any | null
transformStyle: any
viewportBounds?: Bounds
}
const props = defineProps<Props>()
// Flatten the tree structure for rendering
const flattenedNodes = computed(() => {
if (!props.debugInfo?.tree) return []
const nodes: any[] = []
const traverse = (node: any, depth = 0) => {
nodes.push({
bounds: node.bounds,
depth,
itemCount: node.itemCount,
divided: node.divided
})
if (node.children) {
node.children.forEach((child: any) => traverse(child, depth + 1))
}
}
traverse(props.debugInfo.tree)
return nodes
})
// SVG size (matches the transform pane size)
const svgSize = ref({ width: 20000, height: 20000 })
// Apply the same transform as the TransformPane
const svgStyle = computed(() => ({
...props.transformStyle,
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none'
}))
// Color based on depth
const getDepthColor = (depth: number): string => {
const colors = [
'#ff6b6b', // Red
'#ffa500', // Orange
'#ffd93d', // Yellow
'#6bcf7f', // Green
'#4da6ff', // Blue
'#a78bfa' // Purple
]
return colors[depth % colors.length]
}
// Stroke width based on depth
const getStrokeWidth = (depth: number): number => {
return Math.max(0.5, 2 - depth * 0.3)
}
</script>
<style scoped>
.quadtree-visualization {
position: absolute;
overflow: visible;
z-index: 10; /* Above nodes but below UI */
}
</style>

View 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>

View File

@@ -0,0 +1,43 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<div class="flex flex-col gap-1">
<div
class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded max-h-[48rem]"
>
<Chart :type="chartType" :data="chartData" :options="chartOptions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { ChartData } from 'chart.js'
import Chart from 'primevue/chart'
import { computed } from 'vue'
import type { ChartInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#FFF',
usePointStyle: true,
pointStyle: 'circle'
}
}
},
scales: {
x: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: true,
color: '#9FA2BD',
drawTicks: false,
drawOnChartArea: true,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
},
y: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: false,
drawTicks: false,
drawOnChartArea: false,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
}
}
}))
</script>

View File

@@ -0,0 +1,52 @@
<!-- Needs custom color picker for alpha support -->
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
inline
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: '#000000',
emit
})
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,324 @@
<template>
<!-- Replace entire widget with image preview when image is loaded -->
<!-- Edge-to-edge: -mx-2 removes the parent's p-2 (8px) padding on each side -->
<div
v-if="hasImageFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above image -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<!-- TODO: finish once we finish value bindings with Litegraph -->
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Image preview -->
<!-- TODO: change hardcoded colors when design system incorporated -->
<div class="relative group">
<img :src="imageUrl" :alt="selectedFile?.name" class="w-full h-auto" />
<!-- Darkening overlay on hover -->
<div
class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-all duration-200 pointer-events-none"
/>
<!-- Control buttons in top right on hover -->
<div
v-if="!readonly"
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
<!-- Edit button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="handleEdit"
>
<i class="pi pi-pencil text-white text-xs"></i>
</button>
<!-- Delete button -->
<button
class="w-6 h-6 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none"
style="background-color: #262729"
@click="clearFile"
>
<i class="pi pi-times text-white text-xs"></i>
</button>
</div>
</div>
</div>
<!-- Audio preview when audio file is loaded -->
<div
v-else-if="hasAudioFile"
class="relative -mx-2"
style="width: calc(100% + 1rem)"
>
<!-- Select section above audio player -->
<div class="flex items-center justify-between gap-4 mb-2 px-2">
<label
v-if="widget.name"
class="text-xs opacity-80 min-w-[4em] truncate"
>{{ widget.name }}</label
>
<!-- Group select and folder button together on the right -->
<div class="flex items-center gap-1">
<Select
:model-value="selectedFile?.name"
:options="[selectedFile?.name || '']"
:disabled="true"
class="min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
/>
<Button
icon="pi pi-folder"
size="small"
class="!w-8 !h-8"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
<!-- Audio player -->
<div class="relative group px-2">
<div
class="bg-[#1a1b1e] rounded-lg p-4 flex items-center gap-4"
style="border: 1px solid #262729"
>
<!-- Audio icon -->
<div class="flex-shrink-0">
<i class="pi pi-volume-up text-2xl opacity-60"></i>
</div>
<!-- File info and controls -->
<div class="flex-1">
<div class="text-sm font-medium mb-1">{{ selectedFile?.name }}</div>
<div class="text-xs opacity-60">
{{
selectedFile ? (selectedFile.size / 1024).toFixed(1) + ' KB' : ''
}}
</div>
</div>
<!-- Control buttons -->
<div v-if="!readonly" class="flex gap-1">
<!-- Delete button -->
<button
class="w-8 h-8 rounded flex items-center justify-center transition-all duration-150 focus:outline-none border-none hover:bg-[#262729]"
@click="clearFile"
>
<i class="pi pi-times text-white text-sm"></i>
</button>
</div>
</div>
</div>
</div>
<!-- Show normal file upload UI when no image or audio is loaded -->
<div
v-else
class="flex flex-col gap-1 w-full border border-solid p-1 rounded-lg"
:style="{ borderColor: '#262729' }"
>
<div
class="border border-dashed p-1 rounded-md transition-colors duration-200 hover:border-[#5B5E7D]"
:style="{ borderColor: '#262729' }"
>
<div class="flex flex-col items-center gap-2 w-full py-4">
<!-- Quick and dirty file type detection for testing -->
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-xs opacity-60"> Drop your file or </span>
<div>
<Button
label="Browse Files"
size="small"
class="text-xs"
:disabled="readonly"
@click="triggerFileInput"
/>
</div>
</div>
</div>
</div>
<!-- Hidden file input always available for both states -->
<input
ref="fileInputRef"
type="file"
class="hidden"
:accept="widget.options?.accept"
:multiple="false"
:disabled="readonly"
@change="handleFileChange"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
// import { useI18n } from 'vue-i18n' // Commented out for testing
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
// const { t } = useI18n() // Commented out for testing
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const fileInputRef = ref<HTMLInputElement | null>(null)
// Since we only support single file, get the first file
const selectedFile = computed(() => {
const files = localValue.value || []
return files.length > 0 ? files[0] : null
})
// Quick file type detection for testing
const detectFileType = (file: File) => {
const type = file.type?.toLowerCase() || ''
const name = file.name?.toLowerCase() || ''
if (
type.startsWith('image/') ||
name.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)
) {
return 'image'
}
if (type.startsWith('video/') || name.match(/\.(mp4|webm|ogg|mov)$/)) {
return 'video'
}
if (type.startsWith('audio/') || name.match(/\.(mp3|wav|ogg|flac)$/)) {
return 'audio'
}
if (type === 'application/pdf' || name.endsWith('.pdf')) {
return 'pdf'
}
if (type.includes('zip') || name.match(/\.(zip|rar|7z|tar|gz)$/)) {
return 'archive'
}
return 'file'
}
// Check if we have an image file
const hasImageFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'image'
})
// Check if we have an audio file
const hasAudioFile = computed(() => {
return selectedFile.value && detectFileType(selectedFile.value) === 'audio'
})
// Get image URL for preview
const imageUrl = computed(() => {
if (hasImageFile.value && selectedFile.value) {
return URL.createObjectURL(selectedFile.value)
}
return ''
})
// // Get audio URL for playback
// const audioUrl = computed(() => {
// if (hasAudioFile.value && selectedFile.value) {
// return URL.createObjectURL(selectedFile.value)
// }
// return ''
// })
// Clean up image URL when file changes
watch(imageUrl, (newUrl, oldUrl) => {
if (oldUrl && oldUrl !== newUrl) {
URL.revokeObjectURL(oldUrl)
}
})
const triggerFileInput = () => {
fileInputRef.value?.click()
}
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
if (!props.readonly && target.files && target.files.length > 0) {
// Since we only support single file, take the first one
const file = target.files[0]
// Use the composable's onChange handler with an array
onChange([file])
// Reset input to allow selecting same file again
target.value = ''
}
}
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
})
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="flex flex-col gap-1">
<Galleria
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:disabled="readonly"
:show-thumbnails="showThumbnails"
:show-nav-buttons="showNavButtons"
class="max-w-full"
:pt="{
thumbnails: {
class: 'overflow-hidden'
},
thumbnailContent: {
class: 'py-4 px-2'
},
thumbnailPrevButton: {
class: 'm-0'
},
thumbnailNextButton: {
class: 'm-0'
}
}"
>
<template #item="{ item }">
<img
:src="item.itemImageSrc || item.src || item"
:alt="item.alt || 'Gallery image'"
class="w-full h-auto max-h-64 object-contain"
/>
</template>
<template #thumbnail="{ item }">
<div class="p-1 w-full h-full">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-full h-full object-cover rounded-lg"
/>
</div>
</template>
</Galleria>
</div>
</template>
<script setup lang="ts">
import Galleria from 'primevue/galleria'
import { computed, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
GALLERIA_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface GalleryImage {
itemImageSrc?: string
thumbnailImageSrc?: string
src?: string
alt?: string
}
type GalleryValue = string[] | GalleryImage[]
const value = defineModel<GalleryValue>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<GalleryValue>
readonly?: boolean
}>()
const activeIndex = ref(0)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, GALLERIA_EXCLUDED_PROPS)
)
const galleryImages = computed(() => {
if (!value.value || !Array.isArray(value.value)) return []
return value.value.map((item, index) => {
if (typeof item === 'string') {
return {
itemImageSrc: item,
thumbnailImageSrc: item,
alt: `Image ${index + 1}`
}
}
return item
})
})
const showThumbnails = computed(() => {
return (
props.widget.options?.showThumbnails !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
galleryImages.value.length > 1
)
})
</script>
<style scoped>
/* Ensure thumbnail container doesn't overflow */
:deep(.p-galleria-thumbnails) {
overflow: hidden;
}
/* Constrain thumbnail items to prevent overlap */
:deep(.p-galleria-thumbnail-item) {
flex-shrink: 0;
}
/* Ensure thumbnail wrapper maintains aspect ratio */
:deep(.p-galleria-thumbnail) {
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,29 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Image v-bind="filteredProps" :src="widget.value" />
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Image widgets typically don't have v-model, they display a source URL/path
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,70 @@
<template>
<ImageCompare
:tabindex="widget.options?.tabindex ?? 0"
:aria-label="widget.options?.ariaLabel"
:aria-labelledby="widget.options?.ariaLabelledby"
:pt="widget.options?.pt"
:pt-options="widget.options?.ptOptions"
:unstyled="widget.options?.unstyled"
>
<template #left>
<img
:src="beforeImage"
:alt="beforeAlt"
class="w-full h-full object-cover"
/>
</template>
<template #right>
<img
:src="afterImage"
:alt="afterAlt"
class="w-full h-full object-cover"
/>
</template>
</ImageCompare>
</template>
<script setup lang="ts">
import ImageCompare from 'primevue/imagecompare'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue | string>
readonly?: boolean
}>()
const beforeImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? '' : value?.after || ''
})
const beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<WidgetLayoutField :widget="widget">
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div
class="widget-markdown relative w-full cursor-text"
@click="startEditing"
>
<!-- Display mode: Rendered markdown -->
<div
v-if="!isEditing"
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto"
v-html="renderedHtml"
/>
<!-- Edit mode: Textarea -->
<Textarea
v-else
ref="textareaRef"
v-model="localValue"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="6"
:pt="{
root: {
onBlur: handleBlur
}
}"
@update:model-value="onChange"
@click.stop
@keydown.stop
/>
</div>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed, nextTick, ref } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// State
const isEditing = ref(false)
const textareaRef = ref<InstanceType<typeof Textarea> | undefined>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
// Computed
const renderedHtml = computed(() => {
return renderMarkdownToHtml(localValue.value || '')
})
// Methods
const startEditing = async () => {
if (props.readonly || isEditing.value) return
isEditing.value = true
await nextTick()
// Focus the textarea
// @ts-expect-error - $el is an internal property of the Textarea component
textareaRef.value?.$el?.focus()
}
const handleBlur = () => {
isEditing.value = false
}
</script>
<style scoped>
.widget-markdown {
background-color: var(--p-muted-color);
border: 1px solid var(--p-border-color);
border-radius: var(--p-border-radius);
}
.widget-markdown:hover:not(:has(textarea)) {
background-color: var(--p-content-hover-background);
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any[]>
modelValue: any[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div
class="flex items-center justify-between gap-4"
:style="{ height: widgetHeight + 'px' }"
>
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { COMFY_VUE_NODE_DIMENSIONS } from '../../../lib/litegraph/src/litegraph'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<SelectButton
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
:pt="{
pcToggleButton: {
label: 'text-xs'
}
}"
@update:model-value="onChange"
/>
</div>
</template>
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-selectbutton) {
border: 1px solid transparent;
}
:deep(.p-selectbutton:hover) {
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<WidgetLayoutField :widget="widget">
<div class="flex items-center gap-2 w-full">
<Slider
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow text-xs"
@update:model-value="onChange"
/>
<InputText
v-model="inputDisplayValue"
:disabled="readonly"
type="number"
:min="widget.options?.min"
:max="widget.options?.max"
:step="stepValue"
class="w-[4em] text-center text-xs px-0"
size="small"
@blur="handleInputBlur"
@keydown="handleInputKeydown"
/>
</div>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import Slider from 'primevue/slider'
import { computed, ref, watch } from 'vue'
import { useNumberWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<number>
modelValue: number
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: number]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useNumberWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
// Get the precision value for proper number formatting
const precision = computed(() => {
const p = props.widget.options?.precision
// Treat negative or non-numeric precision as undefined
return typeof p === 'number' && p >= 0 ? p : undefined
})
// Calculate the step value based on precision or widget options
const stepValue = computed(() => {
// If step is explicitly defined in options, use it
if (props.widget.options?.step !== undefined) {
return String(props.widget.options.step)
}
// Otherwise, derive from precision
if (precision.value !== undefined) {
if (precision.value === 0) {
return '1'
}
// For precision > 0, step = 1 / (10^precision)
// precision 1 → 0.1, precision 2 → 0.01, etc.
return (1 / Math.pow(10, precision.value)).toFixed(precision.value)
}
// Default to 'any' for unrestricted stepping
return 'any'
})
// Format a number according to the widget's precision
const formatNumber = (value: number): string => {
if (precision.value === undefined) {
// No precision specified, return as-is
return String(value)
}
// Use toFixed to ensure correct decimal places
return value.toFixed(precision.value)
}
// Apply precision-based rounding to a number
const applyPrecision = (value: number): number => {
if (precision.value === undefined) {
// No precision specified, return as-is
return value
}
if (precision.value === 0) {
// Integer precision
return Math.round(value)
}
// Round to the specified decimal places
const multiplier = Math.pow(10, precision.value)
return Math.round(value * multiplier) / multiplier
}
// Keep a separate display value for the input field
const inputDisplayValue = ref(formatNumber(localValue.value))
// Update display value when localValue changes from external sources
watch(localValue, (newValue) => {
inputDisplayValue.value = formatNumber(newValue)
})
const handleInputBlur = (event: Event) => {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
const handleInputKeydown = (event: KeyboardEvent) => {
if (event.key === 'Enter') {
const target = event.target as HTMLInputElement
const value = target.value || '0'
const parsed = parseFloat(value)
if (!isNaN(parsed)) {
// Apply precision-based rounding
const roundedValue = applyPrecision(parsed)
onChange(roundedValue)
// Update display value with proper formatting
inputDisplayValue.value = formatNumber(roundedValue)
}
}
}
</script>
<style scoped>
/* Remove number input spinners */
:deep(input[type='number']::-webkit-inner-spin-button),
:deep(input[type='number']::-webkit-outer-spin-button) {
-webkit-appearance: none;
margin: 0;
}
:deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
rows="3"
@update:model-value="onChange"
/>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,54 @@
<template>
<WidgetLayoutField :widget="widget">
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import { useBooleanWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useBooleanWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-toggleswitch .p-toggleswitch-slider) {
border: 1px solid transparent;
}
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
border-color: currentColor;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<WidgetLayoutField :widget="widget">
<TreeSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
size="small"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import WidgetLayoutField from '@/renderer/extensions/vueNodes/widgets/components/layout/WidgetLayoutField.vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const props = defineProps<{
widget: SimplifiedWidget<any>
modelValue: any
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: null,
emit
})
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
</script>

View 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
}
}

View 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

View 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
}
}

View 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
}
}

View File

@@ -0,0 +1,809 @@
/**
* 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 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('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('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('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()
}
}

View File

@@ -0,0 +1,186 @@
/**
* Level of Detail (LOD) composable for Vue-based node rendering
*
* Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom thresholds to determine
* how much detail to render for each node component.
*
* ## LOD Levels
*
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
*
* ## Performance Benefits
*
* - Reduces DOM element count by up to 80% at low zoom levels
* - Minimizes layout calculations and paint operations
* - Enables smooth performance with 1000+ nodes
* - Maintains visual fidelity when detail is actually visible
*
* @example
* ```typescript
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
*
* // In template
* <NodeWidgets v-if="shouldRenderWidgets" />
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type Ref, computed, readonly } from 'vue'
export enum LODLevel {
MINIMAL = 'minimal', // zoom <= 0.4
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
FULL = 'full' // zoom > 0.8
}
export interface LODConfig {
renderWidgets: boolean
renderSlots: boolean
renderContent: boolean
renderSlotLabels: boolean
renderWidgetLabels: boolean
cssClass: string
}
// LOD configuration for each level
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
[LODLevel.FULL]: {
renderWidgets: true,
renderSlots: true,
renderContent: true,
renderSlotLabels: true,
renderWidgetLabels: true,
cssClass: 'lg-node--lod-full'
},
[LODLevel.REDUCED]: {
renderWidgets: true,
renderSlots: true,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-reduced'
},
[LODLevel.MINIMAL]: {
renderWidgets: false,
renderSlots: false,
renderContent: false,
renderSlotLabels: false,
renderWidgetLabels: false,
cssClass: 'lg-node--lod-minimal'
}
}
/**
* Create LOD (Level of Detail) state based on zoom level
*
* @param zoomRef - Reactive reference to current zoom level (camera.z)
* @returns LOD state and configuration
*/
export function useLOD(zoomRef: Ref<number>) {
// Continuous LOD score (0-1) for smooth transitions
const lodScore = computed(() => {
const zoom = zoomRef.value
return Math.max(0, Math.min(1, zoom))
})
// Determine current LOD level based on zoom
const lodLevel = computed<LODLevel>(() => {
const zoom = zoomRef.value
if (zoom > 0.8) return LODLevel.FULL
if (zoom > 0.4) return LODLevel.REDUCED
return LODLevel.MINIMAL
})
// Get configuration for current LOD level
const lodConfig = computed<LODConfig>(() => LOD_CONFIGS[lodLevel.value])
// Convenience computed properties for common rendering decisions
const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets)
const shouldRenderSlots = computed(() => lodConfig.value.renderSlots)
const shouldRenderContent = computed(() => lodConfig.value.renderContent)
const shouldRenderSlotLabels = computed(
() => lodConfig.value.renderSlotLabels
)
const shouldRenderWidgetLabels = computed(
() => lodConfig.value.renderWidgetLabels
)
// CSS class for styling based on LOD level
const lodCssClass = computed(() => lodConfig.value.cssClass)
// Get essential widgets for reduced LOD (only interactive controls)
const getEssentialWidgets = (widgets: unknown[]): unknown[] => {
if (lodLevel.value === LODLevel.FULL) return widgets
if (lodLevel.value === LODLevel.MINIMAL) return []
// For reduced LOD, filter to essential widget types only
return widgets.filter((widget: any) => {
const type = widget?.type?.toLowerCase()
return [
'combo',
'select',
'toggle',
'boolean',
'slider',
'number'
].includes(type)
})
}
// Performance metrics for debugging
const lodMetrics = computed(() => ({
level: lodLevel.value,
zoom: zoomRef.value,
widgetCount: shouldRenderWidgets.value ? 'full' : 'none',
slotCount: shouldRenderSlots.value ? 'full' : 'none'
}))
return {
// Core LOD state
lodLevel: readonly(lodLevel),
lodConfig: readonly(lodConfig),
lodScore: readonly(lodScore),
// Rendering decisions
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels,
// Styling
lodCssClass,
// Utilities
getEssentialWidgets,
lodMetrics
}
}
/**
* Get LOD level thresholds for configuration or debugging
*/
export const LOD_THRESHOLDS = {
FULL_THRESHOLD: 0.8,
REDUCED_THRESHOLD: 0.4,
MINIMAL_THRESHOLD: 0.0
} as const
/**
* Check if zoom level supports a specific feature
*/
export function supportsFeatureAtZoom(
zoom: number,
feature: keyof LODConfig
): boolean {
const level =
zoom > 0.8
? LODLevel.FULL
: zoom > 0.4
? LODLevel.REDUCED
: LODLevel.MINIMAL
return LOD_CONFIGS[level][feature] as boolean
}

View File

@@ -0,0 +1,180 @@
/**
* Node Change Detection
*
* RAF-based change detection for node positions and sizes.
* Syncs LiteGraph changes to the layout system.
*/
import { reactive } from 'vue'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { layoutMutations } from '@/renderer/core/layout/operations/LayoutMutations'
export interface ChangeDetectionMetrics {
updateTime: number
positionUpdates: number
sizeUpdates: number
rafUpdateCount: number
}
/**
* Change detection for node geometry
*/
export function useNodeChangeDetection(graph: LGraph) {
const metrics = reactive<ChangeDetectionMetrics>({
updateTime: 0,
positionUpdates: 0,
sizeUpdates: 0,
rafUpdateCount: 0
})
// Track last known positions/sizes
const lastSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
/**
* Detects position changes for a single node
*/
const detectPositionChanges = (
node: LGraphNode,
nodePositions: Map<string, { x: number; y: number }>
): boolean => {
const id = String(node.id)
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
void layoutMutations.moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node
*/
const detectSizeChanges = (
node: LGraphNode,
nodeSizes: Map<string, { width: number; height: number }>
): boolean => {
const id = String(node.id)
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
void layoutMutations.resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Main RAF change detection function
*/
const detectChanges = (
nodePositions: Map<string, { x: number; y: number }>,
nodeSizes: Map<string, { width: number; height: number }>,
onSpatialChange?: (node: LGraphNode, id: string) => void
) => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
layoutMutations.setSource('canvas')
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, nodePositions)
const sizeChanged = detectSizeChanges(node, nodeSizes)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Notify spatial change if needed
if ((posChanged || sizeChanged) && onSpatialChange) {
onSpatialChange(node, id)
}
}
// Update metrics
const endTime = performance.now()
metrics.updateTime = endTime - startTime
metrics.positionUpdates = positionUpdates
metrics.sizeUpdates = sizeUpdates
if (positionUpdates > 0 || sizeUpdates > 0) {
metrics.rafUpdateCount++
}
}
/**
* Take a snapshot of current node positions/sizes
*/
const takeSnapshot = () => {
if (!graph?._nodes) return
lastSnapshot.clear()
for (const node of graph._nodes) {
lastSnapshot.set(String(node.id), {
pos: [node.pos[0], node.pos[1]],
size: [node.size[0], node.size[1]]
})
}
}
/**
* Check if any nodes have changed since last snapshot
*/
const hasChangedSinceSnapshot = (): boolean => {
if (!graph?._nodes) return false
for (const node of graph._nodes) {
const id = String(node.id)
const last = lastSnapshot.get(id)
if (!last) continue
if (
last.pos[0] !== node.pos[0] ||
last.pos[1] !== node.pos[1] ||
last.size[0] !== node.size[0] ||
last.size[1] !== node.size[1]
) {
return true
}
}
return false
}
return {
metrics,
detectChanges,
detectPositionChanges,
detectSizeChanges,
takeSnapshot,
hasChangedSinceSnapshot
}
}

View File

@@ -0,0 +1,260 @@
/**
* Node State Management
*
* Manages node visibility, dirty state, and other UI state.
* Provides reactive state for Vue components.
*/
import { nextTick, reactive, readonly } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { PERFORMANCE_CONFIG } from '@/renderer/core/layout/constants'
import type { SafeWidgetData, VueNodeData, WidgetValue } from './useNodeWidgets'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
export interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
}
/**
* Extract safe Vue data from LiteGraph node
*/
export function extractVueNodeData(
node: LGraphNode,
widgets?: SafeWidgetData[]
): VueNodeData {
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,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
}
}
/**
* Node state management composable
*/
export function useNodeState() {
// Reactive state maps
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 node references
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy metadata that auto-GCs
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// 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
/**
* Attach metadata to a node
*/
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high'
})
}
/**
* Get access to original LiteGraph node
*/
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
/**
* Schedule an update for a node
*/
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(),
PERFORMANCE_CONFIG.BATCH_UPDATE_DELAY
)
}
}
}
/**
* Flush all pending updates
*/
const flush = () => {
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Trigger any additional update logic here
}
/**
* Initialize node state
*/
const initializeNode = (node: LGraphNode, vueData: VueNodeData): void => {
const id = String(node.id)
// Store references
nodeRefs.set(id, node)
vueNodeData.set(id, vueData)
// Initialize state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
// Initialize position and size
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Attach metadata
attachMetadata(node)
}
/**
* Clean up node state
*/
const cleanupNode = (nodeId: string): void => {
nodeRefs.delete(nodeId)
vueNodeData.delete(nodeId)
nodeState.delete(nodeId)
nodePositions.delete(nodeId)
nodeSizes.delete(nodeId)
}
/**
* Update node property
*/
const updateNodeProperty = (
nodeId: string,
property: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData) return
if (property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(value)
})
} else if (property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(value)
}
})
}
}
/**
* Update widget state
*/
const updateWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: value as WidgetValue } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
}
return {
// State maps (read-only)
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 }
>,
// Methods
getNode,
attachMetadata,
scheduleUpdate,
flush,
initializeNode,
cleanupNode,
updateNodeProperty,
updateWidgetState,
// Mutable access for internal use
_mutableNodePositions: nodePositions,
_mutableNodeSizes: nodeSizes
}
}

View File

@@ -0,0 +1,182 @@
/**
* Node Widget Management
*
* Handles widget state synchronization between LiteGraph and Vue.
* Provides wrapped callbacks to maintain consistency.
*/
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { WidgetValue } from '@/types/simplifiedWidget'
export type { WidgetValue }
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
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
export function 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
}
/**
* Extract safe widget data from LiteGraph widgets
*/
export function extractWidgetData(
widgets?: any[]
): SafeWidgetData[] | undefined {
if (!widgets) return undefined
return widgets.map((widget) => {
try {
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: validateWidgetValue(value),
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined,
options: undefined,
callback: undefined
}
}
})
}
/**
* Widget callback management for LiteGraph/Vue sync
*/
export function useNodeWidgets() {
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedCallback = (
widget: { value?: unknown; name: string },
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 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
// Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// Update Vue state to maintain synchronization
onUpdate(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node
*/
const setupNodeWidgetCallbacks = (
node: LGraphNode,
onUpdate: (nodeId: string, widgetName: string, value: unknown) => void
) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedCallback(
widget,
originalCallback,
nodeId,
onUpdate
)
})
}
return {
validateWidgetValue,
extractWidgetData,
createWrappedCallback,
setupNodeWidgetCallbacks
}
}

View 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)
}
}

View 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
}
}

View File

@@ -0,0 +1,119 @@
/**
* Widget renderer composable for Vue node system
* Maps LiteGraph widget types to Vue components
*/
import {
WidgetType,
widgetTypeToComponent
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
/**
* Static mapping of LiteGraph widget types to Vue widget component names
* Moved outside function to prevent recreation on every call
*/
const TYPE_TO_ENUM_MAP: Record<string, string> = {
// Number inputs
number: WidgetType.NUMBER,
slider: WidgetType.SLIDER,
INT: WidgetType.INT,
FLOAT: WidgetType.FLOAT,
// Text inputs
text: WidgetType.STRING,
string: WidgetType.STRING,
STRING: WidgetType.STRING,
// Selection
combo: WidgetType.COMBO,
COMBO: WidgetType.COMBO,
selectbutton: WidgetType.SELECTBUTTON,
SELECTBUTTON: WidgetType.SELECTBUTTON,
// Boolean
toggle: WidgetType.TOGGLESWITCH,
boolean: WidgetType.BOOLEAN,
BOOLEAN: WidgetType.BOOLEAN,
// Multiline text
multiline: WidgetType.TEXTAREA,
textarea: WidgetType.TEXTAREA,
customtext: WidgetType.TEXTAREA,
MARKDOWN: WidgetType.MARKDOWN,
// Advanced widgets
color: WidgetType.COLOR,
COLOR: WidgetType.COLOR,
image: WidgetType.IMAGE,
IMAGE: WidgetType.IMAGE,
imagecompare: WidgetType.IMAGECOMPARE,
IMAGECOMPARE: WidgetType.IMAGECOMPARE,
galleria: WidgetType.GALLERIA,
GALLERIA: WidgetType.GALLERIA,
file: WidgetType.FILEUPLOAD,
fileupload: WidgetType.FILEUPLOAD,
FILEUPLOAD: WidgetType.FILEUPLOAD,
// Button widget
button: WidgetType.BUTTON,
BUTTON: WidgetType.BUTTON,
// Chart widget
chart: WidgetType.CHART,
CHART: WidgetType.CHART
} as const
/**
* Pre-computed widget support map for O(1) lookups
* Maps widget type directly to boolean for fast shouldRenderAsVue checks
*/
const WIDGET_SUPPORT_MAP = new Map(
Object.entries(TYPE_TO_ENUM_MAP).map(([type, enumValue]) => [
type,
widgetTypeToComponent[enumValue] !== undefined
])
)
export const ESSENTIAL_WIDGET_TYPES = new Set([
'combo',
'COMBO',
'select',
'toggle',
'boolean',
'BOOLEAN',
'slider',
'number',
'INT',
'FLOAT'
])
export const useWidgetRenderer = () => {
const getWidgetComponent = (widgetType: string): string => {
const enumKey = TYPE_TO_ENUM_MAP[widgetType]
if (enumKey && widgetTypeToComponent[enumKey]) {
return enumKey
}
return WidgetType.STRING
}
const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
}): boolean => {
if (widget.options?.canvasOnly) return false
if (!widget.type) return false
// Check if widget type is explicitly supported
const isSupported = WIDGET_SUPPORT_MAP.get(widget.type)
if (isSupported !== undefined) return isSupported
// Fallback: unknown types are rendered as STRING widget
return widgetTypeToComponent[WidgetType.STRING] !== undefined
}
return {
getWidgetComponent,
shouldRenderAsVue
}
}

View 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)
})
}

View File

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

View File

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

View File

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

View File

@@ -256,6 +256,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',

View 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
}
}

View File

@@ -0,0 +1,29 @@
import {
type ChartInputSpec,
type InputSpec as InputSpecV2,
isChartInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IChartWidget } from '../../lib/litegraph/src/types/widgets'
export const useChartWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IChartWidget => {
if (!isChartInputSpec(inputSpec)) {
throw new Error('Invalid input spec for chart widget')
}
const { name, options = {} } = inputSpec as ChartInputSpec
const chartType = options.type || 'line'
const widget = node.addWidget('chart', name, options.data || {}, () => {}, {
serialize: true,
type: chartType,
...options
}) as IChartWidget
return widget
}
}

View File

@@ -0,0 +1,21 @@
import type {
ColorInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IColorWidget } from '../../lib/litegraph/src/types/widgets'
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
const { name, options } = inputSpec as ColorInputSpec
const defaultValue = options?.default || '#000000'
const widget = node.addWidget('color', name, defaultValue, () => {}, {
serialize: true
}) as IColorWidget
return widget
}
}

View File

@@ -0,0 +1,21 @@
import type {
FileUploadInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IFileUploadWidget } from '../../lib/litegraph/src/types/widgets'
export const useFileUploadWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IFileUploadWidget => {
const { name, options = {} } = inputSpec as FileUploadInputSpec
const widget = node.addWidget('fileupload', name, '', () => {}, {
serialize: true,
...(options as Record<string, unknown>)
}) as IFileUploadWidget
return widget
}
}

View File

@@ -0,0 +1,27 @@
import type {
GalleriaInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IGalleriaWidget } from '../../lib/litegraph/src/types/widgets'
export const useGalleriaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IGalleriaWidget => {
const { name, options = {} } = inputSpec as GalleriaInputSpec
const widget = node.addWidget(
'galleria',
name,
options.images || [],
() => {},
{
serialize: true,
...options
}
) as IGalleriaWidget
return widget
}
}

View File

@@ -0,0 +1,21 @@
import type {
ImageCompareInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IImageCompareWidget } from '../../lib/litegraph/src/types/widgets'
export const useImageCompareWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageCompareWidget => {
const { name, options = {} } = inputSpec as ImageCompareInputSpec
const widget = node.addWidget('imagecompare', name, ['', ''], () => {}, {
serialize: true,
...options
}) as IImageCompareWidget
return widget
}
}

View File

@@ -0,0 +1,21 @@
import type {
ImageInputSpec,
InputSpec as InputSpecV2
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IImageWidget } from '../../lib/litegraph/src/types/widgets'
export const useImageWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IImageWidget => {
const { name, options = {} } = inputSpec as ImageInputSpec
const widget = node.addWidget('image', name, '', () => {}, {
serialize: true,
...options
}) as IImageWidget
return widget
}
}

View File

@@ -0,0 +1,22 @@
import type {
InputSpec as InputSpecV2,
MultiSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { IMultiSelectWidget } from '../../lib/litegraph/src/types/widgets'
export const useMultiSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): IMultiSelectWidget => {
const { name, options = {} } = inputSpec as MultiSelectInputSpec
const widget = node.addWidget('multiselect', name, [], () => {}, {
serialize: true,
values: options.values || [],
...options
}) as IMultiSelectWidget
return widget
}
}

View File

@@ -0,0 +1,29 @@
import type {
InputSpec as InputSpecV2,
SelectButtonInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ISelectButtonWidget } from '../../lib/litegraph/src/types/widgets'
export const useSelectButtonWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ISelectButtonWidget => {
const { name, options = {} } = inputSpec as SelectButtonInputSpec
const values = options.values || []
const widget = node.addWidget(
'selectbutton',
name,
values[0] || '',
(_value: string) => {},
{
serialize: true,
values,
...options
}
) as ISelectButtonWidget
return widget
}
}

View File

@@ -0,0 +1,29 @@
import type {
InputSpec as InputSpecV2,
TextareaInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ITextareaWidget } from '../../lib/litegraph/src/types/widgets'
export const useTextareaWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITextareaWidget => {
const { name, options = {} } = inputSpec as TextareaInputSpec
const widget = node.addWidget(
'textarea',
name,
options.default || '',
() => {},
{
serialize: true,
rows: options.rows || 5,
cols: options.cols || 50,
...options
}
) as ITextareaWidget
return widget
}
}

View File

@@ -0,0 +1,25 @@
import type {
InputSpec as InputSpecV2,
TreeSelectInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import type { LGraphNode } from '../../lib/litegraph/src/litegraph'
import type { ITreeSelectWidget } from '../../lib/litegraph/src/types/widgets'
export const useTreeSelectWidget = (): ComfyWidgetConstructorV2 => {
return (node: LGraphNode, inputSpec: InputSpecV2): ITreeSelectWidget => {
const { name, options = {} } = inputSpec as TreeSelectInputSpec
const isMultiple = options.multiple || false
const defaultValue = isMultiple ? [] : ''
const widget = node.addWidget('treeselect', name, defaultValue, () => {}, {
serialize: true,
values: options.values || [],
multiple: isMultiple,
...options
}) as ITreeSelectWidget
return widget
}
}

View File

@@ -891,5 +891,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: true,
experimental: true
},
{
id: 'Comfy.VueNodes.DebugPanel.Visible',
name: 'Vue Nodes Debug Panel Visible (hidden)',
type: 'hidden',
defaultValue: true,
experimental: true
}
]

View 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['*']
}

View File

@@ -0,0 +1,216 @@
# Test Widgets 节点使用说明
## 概述
这是一个专门用于测试 ComfyUI 各种 Widget 组件的测试节点。它包含了前端支持的所有 widget 类型,方便开发者测试和学习不同组件的使用方法。
## 如何使用
### 1. 启动应用
重新启动 ComfyUI 前端应用以加载新的扩展。
### 2. 添加测试节点
在节点搜索中输入 "Test Widgets" 或者在 "testing" 分类中找到 "Test Widgets" 节点。
### 3. 测试各种 Widget
#### 基础 Widget 类型 (Required)
- **string_widget**: 文本输入框
- **int_widget**: 整数滑块 (0-100)
- **float_widget**: 浮点数滑块 (0.0-10.0)
- **boolean_widget**: 布尔切换开关
- **combo_widget**: 下拉选择框
- **color_widget**: 颜色选择器
- **textarea_widget**: 多行文本输入
- **file_widget**: 文件上传
- **image_widget**: 图片显示
- **markdown_widget**: Markdown 编辑器
#### 高级 Widget 类型 (Optional)
- **selectbutton_widget**: 选择按钮组 (Small/Medium/Large/Extra Large)
- **multiselect_widget**: 多选组件 (颜色选择)
- **treeselect_widget**: 树形选择器 (水果/蔬菜分类)
- **chart_widget**: 图表组件
- **slider_widget**: 数值滑块
- **toggleswitch_widget**: 切换开关
- **button_widget**: 点击按钮
## Widget 配置示例
### SelectButton Widget
```typescript
selectbutton_widget: ['SELECTBUTTON', {
values: ['Small', 'Medium', 'Large', 'Extra Large'],
default: 'Medium'
}]
```
### MultiSelect Widget
```typescript
multiselect_widget: ['MULTISELECT', {
values: ['Red', 'Green', 'Blue', 'Yellow', 'Purple'],
default: ['Red', 'Blue']
}]
```
### TreeSelect Widget
```typescript
treeselect_widget: ['TREESELECT', {
values: [
{
label: 'Fruits',
value: 'fruits',
children: [
{ label: 'Apple', value: 'apple' },
{ label: 'Orange', value: 'orange' }
]
}
]
}]
```
## 输出
节点的输出会显示所有 widget 的当前值,以 JSON 格式呈现:
```json
{
"string_widget": "Hello World",
"int_widget": 42,
"selectbutton_widget": "Medium",
"multiselect_widget": ["Red", "Blue"],
...
}
```
## 开发用途
这个测试节点主要用于:
1. **Widget 开发测试**: 验证新开发的 widget 组件功能
2. **界面设计**: 查看不同 widget 的外观和交互效果
3. **配置学习**: 了解各种 widget 的配置参数
4. **功能演示**: 向其他开发者展示 ComfyUI 的 widget 能力
## 注意事项
- 这是一个开发和测试用的节点,不建议在生产工作流中使用
- 某些 widget (如 IMAGE) 可能需要实际的数据输入才能正常显示
- 按钮点击会触发 alert 提示,这是为了演示按钮功能
## 文件修改清单
在创建此测试组件过程中,对以下文件进行了修改:
### 📁 新增文件
- `src/extensions/core/testWidgets.ts` - 主测试节点文件
- `src/extensions/core/TEST_WIDGETS_README.md` - 本说明文档
### 🔧 修改文件
#### 1. `src/extensions/core/index.ts`
```diff
+ import './testWidgets'
```
**用途**: 注册测试扩展到核心扩展系统
#### 2. `src/schemas/nodeDef/migration.ts`
```diff
// 在 transformInputSpecV1ToV2 函数中添加
+ // Special handling for SELECTBUTTON to ensure options.values is properly set
+ if (inputSpecV1[0] === 'SELECTBUTTON' && options.values) {
+ result.options = {
+ ...result.options,
+ values: options.values
+ }
+ }
```
**用途**: 修复 SELECTBUTTON widget 的数据传递问题
#### 3. `src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue`
```diff
// 模板部分
- v-bind="filteredProps"
+ :options="filteredProps.values || filteredProps.options || []"
// Script 部分添加了调试日志和数据处理
+ const filteredProps = computed(() => {
+ const filtered = filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
+ console.log('WidgetSelectButton filteredProps:', filtered)
+ console.log('Widget options:', props.widget.options)
+
+ // Ensure options array is available for SelectButton
+ if (filtered.values && Array.isArray(filtered.values)) {
+ filtered.options = filtered.values
+ }
+
+ return filtered
+ })
```
**用途**: 修复 PrimeVue SelectButton 组件的属性绑定问题
### 🔄 其他修改的文件
以下文件也被 git 记录为修改,但主要是开发过程中的临时修改:
- `src/components/graph/debug/VueNodeDebugPanel.vue`
- `src/composables/graph/useWidgetRenderer.ts`
- `src/constants/coreSettings.ts`
- `src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.ts`
## 完整还原指南
### 🗑️ 快速清理(推荐)
如果要完全移除测试组件和相关修改:
```bash
# 1. 删除新增的文件
rm src/extensions/core/testWidgets.ts
rm src/extensions/core/TEST_WIDGETS_README.md
# 2. 还原所有修改的文件
git checkout src/extensions/core/index.ts
git checkout src/schemas/nodeDef/migration.ts
git checkout src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue
# 3. 清理其他临时修改(可选)
git checkout src/components/graph/debug/VueNodeDebugPanel.vue
git checkout src/composables/graph/useWidgetRenderer.ts
git checkout src/constants/coreSettings.ts
git checkout src/renderer/extensions/vueNodes/widgets/composables/useWidgetRenderer.ts
# 4. 重新启动应用
```
### 🛠️ 选择性保留
如果想保留 SelectButton 修复但移除测试组件:
```bash
# 1. 只删除测试相关文件
rm src/extensions/core/testWidgets.ts
rm src/extensions/core/TEST_WIDGETS_README.md
# 2. 只还原扩展注册
git checkout src/extensions/core/index.ts
# 3. 保留以下修复这些是有用的bug修复
# - src/schemas/nodeDef/migration.ts
# - src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue
```
### 📝 手动还原
如果不使用 git可以手动还原
#### `src/extensions/core/index.ts`
移除这一行:
```typescript
import './testWidgets'
```
#### `src/schemas/nodeDef/migration.ts`
删除 SELECTBUTTON 的特殊处理代码第130-135行左右
#### `src/renderer/extensions/vueNodes/widgets/components/WidgetSelectButton.vue`
-`:options="filteredProps.values || filteredProps.options || []"` 改回 `v-bind="filteredProps"`
- 移除 console.log 调试语句
- 恢复原始的 `filteredProps` computed 函数
## 注意事项
- ⚠️ **SelectButton 修复很重要**: `migration.ts``WidgetSelectButton.vue` 的修改修复了实际的 bug建议保留
- 🧹 **清理调试日志**: 如果保留修复,记得移除 console.log 调试语句
- 🔄 **重启应用**: 任何文件修改后都需要重新启动应用才能生效
- 💾 **备份重要修改**: 在还原前考虑是否需要保留某些有用的修复

View File

@@ -122,7 +122,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)
}

View File

@@ -16,6 +16,7 @@ import './saveImageExtraOutput'
import './saveMesh'
import './simpleTouchSupport'
import './slotDefaults'
import './testWidgets'
import './uploadAudio'
import './uploadImage'
import './webcamCapture'

View File

@@ -0,0 +1,252 @@
/**
* Test Widgets Extension - 用于测试各种 Widget 组件的测试节点
*/
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { ComfyWidgets } from '@/scripts/widgets'
app.registerExtension({
name: 'comfy.TestWidgets',
registerCustomNodes() {
class TestWidgetsNode extends LGraphNode {
static override title = 'Test Widgets'
static override category = 'testing'
constructor(title: string) {
super(title)
this.title = 'Test Widgets'
this.size = [500, 1200]
this.addOutput('output', 'STRING')
this.addTestWidgets()
this.serialize_widgets = true
}
addTestWidgets() {
try {
ComfyWidgets.STRING(
this,
'string_input',
['STRING', { default: 'Hello World', placeholder: '输入文本' }],
app
)
ComfyWidgets.INT(
this,
'int_input',
['INT', { default: 42, min: 0, max: 100, step: 1 }],
app
)
ComfyWidgets.FLOAT(
this,
'float_input',
['FLOAT', { default: 3.14, min: 0.0, max: 10.0, step: 0.1 }],
app
)
ComfyWidgets.BOOLEAN(
this,
'boolean_toggle',
['BOOLEAN', { default: true }],
app
)
ComfyWidgets.COMBO(
this,
'combo_select',
[
'COMBO',
{
values: ['Option A', 'Option B', 'Option C'],
default: 'Option A'
}
],
app
)
ComfyWidgets.SELECTBUTTON(
this,
'theme_selector',
[
'SELECTBUTTON',
{
values: ['Light', 'Dark', 'Auto'],
default: 'Auto'
}
],
app
)
ComfyWidgets.MULTISELECT(
this,
'multi_select',
[
'MULTISELECT',
{
values: ['Red', 'Green', 'Blue', 'Yellow', 'Purple'],
default: ['Red', 'Blue']
}
],
app
)
ComfyWidgets.TREESELECT(
this,
'tree_select',
[
'TREESELECT',
{
values: [
{
key: '0',
label: 'Root',
children: [
{ key: '0-0', label: 'Child 1' },
{ key: '0-1', label: 'Child 2' }
]
}
],
default: '0-0'
}
],
app
)
ComfyWidgets.TEXTAREA(
this,
'textarea_input',
[
'TEXTAREA',
{
default: 'This is a multiline\ntext area for longer content.',
rows: 4
}
],
app
)
ComfyWidgets.COLOR(
this,
'color_picker',
['COLOR', { default: 'ff6b6b' }],
app
)
ComfyWidgets.FILEUPLOAD(
this,
'file_upload',
[
'FILEUPLOAD',
{
accept: '.jpg,.png,.gif,.webp',
multiple: false
}
],
app
)
// ComfyWidgets.MARKDOWN(
// this,
// 'markdown_display',
// ['MARKDOWN', {
// default: `# Test Markdown Widget
// This is a **Markdown** rendering component test.
// - Supports lists
// - Supports *italic* and **bold** text
// - Supports \`code\`
// > This is quoted text
// \`\`\`javascript
// console.log('Hello World!');
// \`\`\``
// }],
// app
// )
// ComfyWidgets.IMAGE(
// this,
// 'image_display',
// ['IMAGE', {
// default: null,
// width: 200,
// height: 200
// }],
// app
// )
// ComfyWidgets.IMAGECOMPARE(
// this,
// 'image_compare',
// ['IMAGECOMPARE', {
// default: { before: null, after: null }
// }],
// app
// )
// ComfyWidgets.GALLERIA(
// this,
// 'image_gallery',
// ['GALLERIA', {
// default: [],
// thumbnailSize: 100
// }],
// app
// )
// ComfyWidgets.CHART(
// this,
// 'chart_display',
// ['CHART', {
// default: {
// type: 'line',
// data: {
// labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
// datasets: [{
// label: 'Test Data',
// data: [10, 20, 15, 25, 30],
// borderColor: '#42A5F5',
// backgroundColor: '#42A5F5'
// }]
// },
// options: {
// responsive: true,
// plugins: {
// title: {
// display: true,
// text: 'Test Chart Widget'
// }
// }
// }
// }
// }],
// app
// )
} catch (error) {
console.error('Error adding widgets:', error)
}
}
override onExecute() {
// 收集所有 widget 的值
const values: Record<string, any> = {}
if (this.widgets) {
for (const widget of this.widgets) {
values[widget.name] = widget.value
}
}
// 输出 JSON 格式的值
this.setOutputData(0, JSON.stringify(values, null, 2))
}
}
LiteGraph.registerNodeType('TestWidgets', TestWidgetsNode)
}
})

View File

@@ -24,39 +24,3 @@
- Be sure to typecheck when youre done making a series of code changes
- Prefer running single tests, and not the whole test suite, for performance
# Testing Guidelines
## Avoiding Circular Dependencies in Tests
**CRITICAL**: When writing tests for subgraph-related code, always import from the barrel export to avoid circular dependency issues:
```typescript
// ✅ CORRECT - Use barrel import
import { LGraph, Subgraph, SubgraphNode } from "@/litegraph"
// ❌ WRONG - Direct imports cause circular dependency
import { LGraph } from "@/LGraph"
import { Subgraph } from "@/subgraph/Subgraph"
import { SubgraphNode } from "@/subgraph/SubgraphNode"
```
**Root cause**: `LGraph` and `Subgraph` have a circular dependency:
- `LGraph.ts` imports `Subgraph` (creates instances with `new Subgraph()`)
- `Subgraph.ts` extends `LGraph`
The barrel export (`@/litegraph`) handles this properly, but direct imports cause module loading failures.
## Test Setup for Subgraphs
Use the provided test helpers for consistent setup:
```typescript
import { createTestSubgraph, createTestSubgraphNode } from "./fixtures/subgraphHelpers"
function createTestSetup() {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
return { subgraph, subgraphNode }
}
```

View File

@@ -1,4 +1,8 @@
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/LitegraphLinkAdapter'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -47,7 +51,6 @@ import {
containsRect,
createBounds,
distance,
findPointOnCurve,
isInRect,
isInRectangle,
isPointInRect,
@@ -232,9 +235,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='
@@ -636,6 +636,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. */
@@ -697,6 +700,11 @@ 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)
}
this.linkConnector.events.addEventListener('link-created', () =>
this.#dirty()
)
@@ -1790,6 +1798,9 @@ export class LGraphCanvas
this.clear()
newGraph.attachCanvas(this)
// Re-initialize link renderer with new graph
this.linkRenderer = new LitegraphLinkAdapter(newGraph)
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()
}
@@ -2410,7 +2421,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)
@@ -4538,18 +4552,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)
@@ -4622,6 +4644,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
@@ -4947,6 +4974,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
@@ -5660,6 +5700,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
@@ -5701,333 +5769,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
)
}
}

View File

@@ -1,3 +1,11 @@
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateInputSlotPosFromSlot,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/SlotCalculations'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
@@ -257,6 +265,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}
@@ -756,6 +768,9 @@ export class LGraphNode
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. */
@@ -1958,6 +1973,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
}
@@ -3209,6 +3232,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.
*
@@ -3217,7 +3259,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)
}
/**
@@ -3226,25 +3268,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)
}
/**
@@ -3255,29 +3279,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 */
@@ -3823,12 +3825,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 {
@@ -4024,14 +4047,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)
}
}
}

View 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
}
}

View File

@@ -14,7 +14,6 @@ import type {
LinkSegment,
ReadonlyLinkNetwork
} from './interfaces'
import { Subgraph } from './litegraph'
import type {
Serialisable,
SerialisableLLink,
@@ -451,16 +450,6 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
}
}
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
})
}
}
/**

View File

@@ -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,13 @@ 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.
*/
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'
@@ -328,6 +361,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

View File

@@ -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
}
/**

View File

@@ -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()
@@ -132,7 +132,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 { clamp, createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'

View File

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

View File

@@ -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[]

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

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

View 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()
})
})
})

View File

@@ -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,
}
`;

View File

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

View File

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

View File

@@ -230,12 +230,21 @@
"Workspace_ToggleBottomPanel": {
"label": "Toggle Bottom Panel"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Show Keybindings Dialog"
},
"Workspace_ToggleBottomPanelTab_command-terminal": {
"label": "Toggle Terminal Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Toggle Logs Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Toggle Essential Bottom Panel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Toggle View Controls Bottom Panel"
},
"Workspace_ToggleFocusMode": {
"label": "Toggle Focus Mode"
},

View File

@@ -56,6 +56,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"next": "Next",
"install": "Install",
@@ -1008,8 +1009,11 @@
"Previous Opened Workflow": "Previous Opened Workflow",
"Toggle Search Box": "Toggle Search Box",
"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",
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
@@ -1073,7 +1077,8 @@
"User": "User",
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences"
"Notification Preferences": "Notification Preferences",
"Vue Nodes": "Vue Nodes"
},
"serverConfigItems": {
"listen": {

View File

@@ -339,6 +339,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.",

View File

@@ -236,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Alternar Panel Inferior de Registros"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Alternar panel inferior esencial"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Alternar panel inferior de controles de vista"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Mostrar diálogo de combinaciones de teclas"
},
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"
},

View File

@@ -298,6 +298,7 @@
"disabling": "Deshabilitando",
"dismiss": "Descartar",
"download": "Descargar",
"dropYourFileOr": "Suelta tu archivo o",
"edit": "Editar",
"empty": "Vacío",
"enableAll": "Habilitar todo",
@@ -807,9 +808,11 @@
"Restart": "Reiniciar",
"Save": "Guardar",
"Save As": "Guardar como",
"Show Keybindings Dialog": "Mostrar diálogo de combinaciones de teclas",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
"Toggle Model Library Sidebar": "Alternar barra lateral de la biblioteca de modelos",
@@ -818,6 +821,7 @@
"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",
@@ -1141,9 +1145,24 @@
"UV": "UV",
"User": "Usuario",
"Validation": "Validación",
"Vue Nodes": "Nodos Vue",
"Window": "Ventana",
"Workflow": "Flujo de Trabajo"
},
"shortcuts": {
"essentials": "Esenciales",
"keyboardShortcuts": "Atajos de teclado",
"manageShortcuts": "Gestionar atajos",
"noKeybinding": "Sin asignación de tecla",
"subcategories": {
"node": "Nodo",
"panelControls": "Controles del panel",
"queue": "Cola",
"view": "Vista",
"workflow": "Flujo de trabajo"
},
"viewControls": "Controles de vista"
},
"sideToolbar": {
"browseTemplates": "Explorar plantillas de ejemplo",
"downloads": "Descargas",

View File

@@ -339,6 +339,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": {

View File

@@ -236,6 +236,15 @@
"Workspace_ToggleBottomPanelTab_logs-terminal": {
"label": "Basculer le panneau inférieur des journaux"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "Afficher/Masquer le panneau inférieur essentiel"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Afficher/Masquer le panneau inférieur des contrôles de vue"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Afficher la boîte de dialogue des raccourcis clavier"
},
"Workspace_ToggleFocusMode": {
"label": "Basculer le mode focus"
},

View File

@@ -298,6 +298,7 @@
"disabling": "Désactivation",
"dismiss": "Fermer",
"download": "Télécharger",
"dropYourFileOr": "Déposez votre fichier ou",
"edit": "Modifier",
"empty": "Vide",
"enableAll": "Activer tout",
@@ -807,9 +808,11 @@
"Restart": "Redémarrer",
"Save": "Enregistrer",
"Save As": "Enregistrer sous",
"Show Keybindings Dialog": "Afficher la boîte de dialogue des raccourcis clavier",
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Essential Bottom Panel": "Afficher/Masquer le panneau inférieur essentiel",
"Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
@@ -818,6 +821,7 @@
"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": "Afficher/Masquer le panneau inférieur des contrôles de vue",
"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",
@@ -1141,9 +1145,24 @@
"UV": "UV",
"User": "Utilisateur",
"Validation": "Validation",
"Vue Nodes": "Nœuds Vue",
"Window": "Fenêtre",
"Workflow": "Flux de Travail"
},
"shortcuts": {
"essentials": "Essentiel",
"keyboardShortcuts": "Raccourcis clavier",
"manageShortcuts": "Gérer les raccourcis",
"noKeybinding": "Aucun raccourci",
"subcategories": {
"node": "Nœud",
"panelControls": "Contrôles du panneau",
"queue": "File dattente",
"view": "Affichage",
"workflow": "Flux de travail"
},
"viewControls": "Contrôles daffichage"
},
"sideToolbar": {
"browseTemplates": "Parcourir les modèles d'exemple",
"downloads": "Téléchargements",

Some files were not shown because too many files have changed in this diff Show More