[feat] Vue-Based Rendering System for the ComfyUI Node Graph (#4263)

* [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

* [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

* [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

* [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

* [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

* [feat] Add Vue action widgets

- WidgetButton: Action button with Button component and callback handling
- WidgetFileUpload: File upload interface with FileUpload component

* [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>

* Update locales [skip ci]

* Fix TransformPane pos/size (#4826)

* Update locales [skip ci]

* 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.

* 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.

* test(ci): skip transformPerformance suite on CI (#4843)

* Add vue node feature flag (#4927)

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

* [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>

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

* [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.

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

* [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>

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

* remove logging from vue node layouting modules (#5111)

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

* Revert "feat: Add slot registration and spatial indexing for hit detection"

This reverts commit 70fbfd0f5e.

* [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>

* [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>

* 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

* Update lockfile after rebase (#5254)

* chore: Update pnpm-lock.yaml after rebase

Add new dependencies from main branch:
- chart.js@^4.5.0
- clsx@^2.1.1
- tailwind-merge@^3.3.1
- yjs@^13.6.27

* Fix SelectionOverlay rebase issue (#5255)

* fix: Remove SelectionOverlay import accidentally re-added during rebase

During the rebase, the SelectionOverlay component import and usage was accidentally
re-introduced. This component was removed in commit 84e7102f (#5158) to fix
performance issues. The SelectionToolbox should be used directly without a wrapper.

The current main branch correctly uses:
<SelectionToolbox v-if="selectionToolboxEnabled" />

Ref: https://github.com/Comfy-Org/ComfyUI_frontend/pull/5158

* Deduplicate i18n keys from rebasing (#5257)

* fix: Add missing comma in zh locale JSON

Fixes JSON syntax error introduced during rebase.

* dedup i18n keys

* fix: Restore simplified Chinese translation for Toggle Workflows Sidebar

The previous dedup commit accidentally left a traditional Chinese
translation in the simplified Chinese locale file.

* fix: Replace remaining traditional Chinese characters in simplified Chinese locale

- Changed '檔案' to '文件' (file)
- Changed '擴充功能' to '扩展功能' (extensions)

* Fix lodash import (#5269)

* Decouple link and slot hit-testing out of Litegraph (#5134)

* [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>

* Update locales [skip ci]

* Update locales [skip ci]

* Add vue node feature flag (#4927)

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

* [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>

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

* Revert "feat: Add slot registration and spatial indexing for hit detection"

This reverts commit 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>

* relocate slot update to layoutstore

* Revert "relocate slot update to layoutstore"

This reverts commit 0b17ef148bdded35cb231bef25b8d5c77dc14c1f.

* add useSlotLayoutSync

* feat: Extend Layout Store with CRDT support for links and reroutes

Move links and reroutes to be first-class CRDT entities in the Layout Store,
eliminating per-frame registration during rendering. This provides a ~100x
reduction in spatial index operations by using event-driven updates instead
of polling.

Key changes:
- Add CRDT maps for links and reroutes with automatic observers
- Add mutation operations for link/reroute lifecycle management
- Update LiteGraph to use mutations instead of direct store calls
- Remove per-frame updateLinkLayout and updateRerouteLayout calls

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

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

* Scuffed diff, change to dirty later

* Fix reroute move desync

* Terrible reroute fixes

* Use LinkId for LinkLayout

* refactor: Remove unused duplicate layout type files

Deleted src/types/layoutTypes.ts and src/types/layoutOperations.ts
which were duplicates of src/renderer/core/layout/types.ts. These
files had zero imports and were creating confusion in the codebase.

The active types are in src/renderer/core/layout/types.ts which
is properly integrated with the current architecture.

🤖 Generated with Claude Code

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

* refactor: Extract layout source strings into LayoutSource enum

Replace hardcoded 'canvas' | 'vue' | 'external' string literals with a proper TypeScript enum for better type safety and maintainability. This change provides a single source of truth for layout source types and makes future modifications easier.

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

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

* refactor: Unify CRDT layout operations under type-safe entity bases

Replace node-centric BaseOperation with a clean hierarchy:
- Add OperationMeta base containing common fields (timestamp, actor, source, type)
- Introduce entity-specific bases (NodeOpBase, LinkOpBase, RerouteOpBase)
- Each operation now extends its appropriate entity base with proper typing
- Add entity discriminator field for runtime type narrowing

Benefits:
- Eliminates duplicate meta fields across link/reroute operations
- Provides type-safe discriminated unions for each entity type
- Enables clean extension path for future operation types
- Zero breaking changes - type-only refactor with no runtime impact

Also adds helper functions:
- getAffectedNodeIds() to extract node IDs affected by any operation
- Entity-specific helper checks for operation classification

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

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

* Fix initial link seeding

* fix: Fix reroute hit detection and type consistency issues

- Use instanceof Reroute type guard instead of structural 'linkIds' check
- Remove unnecessary Number() conversions for reroute IDs (already numeric)
- Fix parentId truthiness bug (0 is valid parent ID)
- Pass numeric IDs directly in GraphCanvas seeding
- Add missing link/reroute methods to LayoutMutations interface
- Make hit test tolerance scale-aware using ctx.lineWidth and DPI

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

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

* Add debug logs

* Add missing reroute path

* cleanup

* feat: Implement event-driven link layout sync

Remove layout store writes from render loop and update link geometry only on
actual changes (node move/resize, link/reroute operations, collapse toggles).

Key improvements:
- No layout writes during canvas render (decoupled from draw cycle)
- Link layouts update only on causal events via useLinkLayoutSync
- Hit testing remains precise using stored Path2D objects
- Optimized adapter: calculations only when enableLayoutStoreWrites=true
- Store-level deduplication prevents spatial index churn

Performance impact:
- Render path: Zero layout work, no equality checks, no store writes
- Event path: Direct writes with cheap store-level dedup
- Significant CPU savings per frame on complex graphs

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

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

* feat: Implement DOM-based slot registration with unified position system

- Add centralized getSlotPosition() function in SlotCalculations
- Create SlotIdentifier utilities for consistent slot key generation
- Implement DOM-based slot registration composable with performance optimizations:
  - Cache slot offsets to avoid DOM reads during drag operations
  - Batch measurements via requestAnimationFrame
  - Skip redundant updates when bounds unchanged
- Update Vue slot components to register DOM positions
- Fix widget-to-input index mapping in NodeWidgets
- Prevent double registration when Vue nodes enabled

This improves slot hit-detection accuracy by using actual DOM positions
while maintaining performance through intelligent caching and batching.

🤖 Generated with Claude Code

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

* Remove unused files

* Remove duplicated markdown file

* Remove duplicated files and address knip concerns

* Remove outdated test

* warning comment

* Update test snapshots

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions <github-actions@github.com>

* chore: Empty commit to trigger CI checks

* [refactor] Remove unused legacy mutation types from layout system (#5262)

- Remove LayoutMutationType, LayoutMutation, and related interfaces
- Remove AnyLayoutMutation union type and specific mutation interfaces
- Clean up duplicate legacy types from both layoutTypes.ts and layout/types.ts
- Fix JSON syntax error in Chinese locale file (missing comma)
- Replace lodash with es-toolkit in useFloatWidget (per project standards)
- Reduces codebase by ~120 lines of unused type definitions
- CRDT operations (LayoutOperation) remain unchanged and functional

The legacy mutation types were designed for backward compatibility
but have never been used since this code hasn't been merged to main.
Only the CRDT operation types are actually used in the implementation.

* feat: localization fields (#5318)

* fix: remove clipping by removing unnecessary css contain (#5327)

* [bugfix] Remove placeholder IMAGE widget to restore previous functionality (#5349)

* Remove IMAGE widget

* Remove IMAGE widget test expectations

* - Convert class-based LayoutMutations to useLayoutMutations() composable (#5346)

- Remove unnecessary useLayout wrapper that added boilerplate
- Use LayoutMutations interface directly in LGraph instead of redefining types
- Update all components to use composable pattern consistently

* feat: widget styles for V3 UI (#5320)

* feat: widget input text style

* feat: widget select button style

* feat: the selection style of LGraphNode

* feat(V3 UI style): color picker + file upload + input text + multi select + select + select button + slider + textarea + tree select

* feat: placeholder

* fix: filter multi select options

* fix: direct binding, no transform for select button widget

* refactor: v3 ui slots connection dots (#5316)

* refactor: v3 ui slots connection dots

* fix: use the new useTemplateRef

* fix: slot dark-theme border and hover styles

---------

Co-authored-by: Christian Byrne <cbyrne@comfy.org>

* add explicit typing on component IDs (#5352)

* Remove IMAGE widget cont. (#5355)

* Removes node's dependency on LGraph for access to layout mutations composable (#5356)

* remove DI

* remove layoutMutations property on LGraph

* remove layout mutations property from LGraph snapshot

* [fix] Disable link markers on dragged connections (#5358)

Set linkMarkerShape to None for links being actively dragged by the mouse to prevent visual artifacts.

* [bugfix] Fix NodeHeader test workflow path (#5359)

The test was using an incorrect path for the workflow file. Updated to use the correct path under the nodes/ subdirectory.

Fixes test failure: ENOENT error for single_save_image_node.json

* [Vue Nodes] Fix Node Header Tests (#5360)

* Enable VueNodes

* Use KSampler not save image

* Update test expectations [skip ci]

* remove crdt ADR (moved to separate PR)

* update adr README

* removed unused IMAGE widget enum value

* remove all unused (knip pass)

* remove debug overlay panel

* simplify unit tests

* change name "transformPaneEnabled" => "isVueNodesEnabled"

* remove debug viewport visualizer

* remove debug viewport visualizer prop

* remove outdated README

* skip all vue node operations if feature is turned off

* remove debug logging and setting

* remove event forwarding hack. todo: add link moving in vue

* cleanup comments

* cleanup comments

* add missing translations

* use camelCase for all non-component files

* remove debug viewport test

* - Fix memory leaks in node deletion (#5345)

- Fix TypeScript types in Yjs observers with proper YEventChange type
- Refactor nested observer logic into focused single-responsibility methods
- Consolidate duplicated link segment cleanup logic into reusable methods
- Extract findLinksConnectedToNode method for better readability
- Add explanatory comments for spatial index update ordering
- Extract REROUTE_RADIUS constant instead of magic numbers
- Maintain consistent parameter naming conventions

* remove redundant comment

* use camelcase for layoutStore filename

* removed unused type guards

* simplify widget registration

* move back test that was mistakenly moved

* remove unused typeguards

* removed unused node def type guards

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: Rizumu Ayaka <rizumu@ayaka.moe>
Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
This commit is contained in:
Christian Byrne
2025-09-04 21:31:59 -07:00
committed by snomiao
parent dfc6dfd6a2
commit 8589615602
171 changed files with 18028 additions and 558 deletions

View File

@@ -22,7 +22,7 @@
# Workflow
- Be sure to typecheck when youre done making a series of code changes
- Be sure to typecheck when you're done making a series of code changes
- Prefer running single tests, and not the whole test suite, for performance
# Testing Guidelines

View File

@@ -6,6 +6,8 @@ import {
} from '@/lib/litegraph/src/constants'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { DragAndScaleState } from './DragAndScale'
import { LGraphCanvas } from './LGraphCanvas'
@@ -1336,6 +1338,7 @@ export class LGraph
* @returns The newly created reroute - typically ignored.
*/
createReroute(pos: Point, before: LinkSegment): Reroute {
const layoutMutations = useLayoutMutations()
const rerouteId = ++this.state.lastRerouteId
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
@@ -1349,6 +1352,16 @@ export class LGraph
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(
rerouteId,
{ x: pos[0], y: pos[1] },
before.parentId,
Array.from(linkIds)
)
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
@@ -1379,6 +1392,7 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1422,6 +1436,11 @@ export class LGraph
}
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
this.setDirtyCanvas(false, true)
@@ -2105,6 +2124,7 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2245,6 +2265,9 @@ export class LGraph
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}

View File

@@ -2,6 +2,11 @@ import { toString } from 'es-toolkit/compat'
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
import { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
import {
type LinkRenderContext,
LitegraphLinkAdapter
} from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -51,7 +56,6 @@ import {
containsRect,
createBounds,
distance,
findPointOnCurve,
isInRect,
isInRectangle,
isPointInRect,
@@ -235,9 +239,6 @@ export class LGraphCanvas
static #tmp_area = new Float32Array(4)
static #margin_area = new Float32Array(4)
static #link_bounding = new Float32Array(4)
static #lTempA: Point = new Float32Array(2)
static #lTempB: Point = new Float32Array(2)
static #lTempC: Point = new Float32Array(2)
static DEFAULT_BACKGROUND_IMAGE =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
@@ -679,6 +680,9 @@ export class LGraphCanvas
/** Set on keydown, keyup. @todo */
#shiftDown: boolean = false
/** Link rendering adapter for litegraph-to-canvas integration */
linkRenderer: LitegraphLinkAdapter | null = null
/** If true, enable drag zoom. Ctrl+Shift+Drag Up/Down: zoom canvas. */
dragZoomEnabled: boolean = false
/** The start position of the drag zoom. */
@@ -748,6 +752,13 @@ export class LGraphCanvas
}
}
// Initialize link renderer if graph is available
if (graph) {
this.linkRenderer = new LitegraphLinkAdapter(graph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
}
this.linkConnector.events.addEventListener('link-created', () =>
this.#dirty()
)
@@ -1843,6 +1854,11 @@ export class LGraphCanvas
this.clear()
newGraph.attachCanvas(this)
// Re-initialize link renderer with new graph
this.linkRenderer = new LitegraphLinkAdapter(newGraph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()
}
@@ -2236,11 +2252,22 @@ export class LGraphCanvas
this.processSelect(node, e, true)
} else if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Reroutes
const reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this.#visibleReroutes
)
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: e.canvasX,
y: e.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
reroute = graph.getReroute(rerouteLayout.id)
} else {
reroute = graph.getRerouteOnPos(
e.canvasX,
e.canvasY,
this.#visibleReroutes
)
}
if (reroute) {
if (e.altKey) {
pointer.onClick = (upEvent) => {
@@ -2406,8 +2433,18 @@ export class LGraphCanvas
// Reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
// Try layout store first for hit detection
const rerouteLayout = layoutStore.queryRerouteAtPoint({ x, y })
let foundReroute: Reroute | undefined
if (rerouteLayout) {
foundReroute = graph.getReroute(rerouteLayout.id)
}
// Fallback to checking visible reroutes directly
for (const reroute of this.#visibleReroutes) {
const overReroute = reroute.containsPoint([x, y])
const overReroute =
foundReroute === reroute || reroute.containsPoint([x, y])
if (!reroute.isSlotHovered && !overReroute) continue
if (overReroute) {
@@ -2441,16 +2478,32 @@ export class LGraphCanvas
this.ctx.lineWidth = this.connections_width + 7
const dpi = Math.max(window?.devicePixelRatio ?? 1, 1)
// Try layout store for segment hit testing first (more precise)
const hitSegment = layoutStore.queryLinkSegmentAtPoint({ x, y }, this.ctx)
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
// Check if this link segment was hit
let isLinkHit =
hitSegment &&
linkSegment.id ===
(linkSegment instanceof Reroute
? hitSegment.rerouteId
: hitSegment.linkId)
if (!isLinkHit && linkSegment.path) {
// Fallback to direct path hit testing if not found in layout store
isLinkHit = this.ctx.isPointInStroke(
linkSegment.path,
x * dpi,
y * dpi
)
}
// If we shift click on a link then start a link from that input
if (
(e.shiftKey || e.altKey) &&
linkSegment.path &&
this.ctx.isPointInStroke(linkSegment.path, x * dpi, y * dpi)
) {
if ((e.shiftKey || e.altKey) && isLinkHit) {
this.ctx.lineWidth = lineWidth
if (e.shiftKey && !e.altKey) {
@@ -2465,7 +2518,10 @@ export class LGraphCanvas
pointer.onDragEnd = (e) => this.#processDraggedItems(e)
return
}
} else if (isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)) {
} else if (
this.linkMarkerShape !== LinkMarkerShape.None &&
isInRectangle(x, y, centre[0] - 4, centre[1] - 4, 8, 8)
) {
this.ctx.lineWidth = lineWidth
pointer.onClick = () => this.showLinkMenu(linkSegment, e)
@@ -3178,8 +3234,27 @@ export class LGraphCanvas
// For input/output hovering
// to store the output of isOverNodeInput
const pos: Point = [0, 0]
const inputId = isOverNodeInput(node, x, y, pos)
const outputId = isOverNodeOutput(node, x, y, pos)
// Try to use layout store for hit testing first, fallback to old method
let inputId: number = -1
let outputId: number = -1
const slotLayout = layoutStore.querySlotAtPoint({ x, y })
if (slotLayout && slotLayout.nodeId === String(node.id)) {
if (slotLayout.type === 'input') {
inputId = slotLayout.index
pos[0] = slotLayout.position.x
pos[1] = slotLayout.position.y
} else {
outputId = slotLayout.index
pos[0] = slotLayout.position.x
pos[1] = slotLayout.position.y
}
} else {
// Fallback to old method
inputId = isOverNodeInput(node, x, y, pos)
outputId = isOverNodeOutput(node, x, y, pos)
}
const overWidget = node.getWidgetOnPos(x, y, true) ?? undefined
if (!node.mouseOver) {
@@ -4640,18 +4715,28 @@ export class LGraphCanvas
: LiteGraph.CONNECTING_LINK_COLOR
// the connection being dragged by the mouse
this.renderLink(
ctx,
pos,
highlightPos,
null,
false,
null,
colour,
fromDirection,
dragDirection
)
if (this.linkRenderer) {
this.linkRenderer.renderLinkDirect(
ctx,
pos,
highlightPos,
null,
false,
null,
colour,
fromDirection,
dragDirection,
{
...this.buildLinkRenderContext(),
linkMarkerShape: LinkMarkerShape.None
},
{
disabled: false
}
)
}
ctx.fillStyle = colour
ctx.beginPath()
if (connType === LiteGraph.EVENT || connShape === RenderShape.BOX) {
ctx.rect(pos[0] - 6 + 0.5, pos[1] - 5 + 0.5, 14, 10)
@@ -4724,6 +4809,11 @@ export class LGraphCanvas
/** @returns If the pointer is over a link centre marker, the link segment it belongs to. Otherwise, `undefined`. */
#getLinkCentreOnPos(e: CanvasPointerEvent): LinkSegment | undefined {
// Skip hit detection if center markers are disabled
if (this.linkMarkerShape === LinkMarkerShape.None) {
return undefined
}
for (const linkSegment of this.renderedPaths) {
const centre = linkSegment._pos
if (!centre) continue
@@ -5049,6 +5139,19 @@ export class LGraphCanvas
drawNode(node: LGraphNode, ctx: CanvasRenderingContext2D): void {
this.current_node = node
// When Vue nodes mode is enabled, LiteGraph should not draw node chrome or widgets.
// We still need to keep slot metrics and layout in sync for hit-testing and links.
// Interaction system changes coming later, chances are vue nodes mode will be mostly broken on land
if (LiteGraph.vueNodesMode) {
// Prepare concrete slots and compute layout measures without rendering visuals.
node._setConcreteSlots()
if (!node.collapsed) {
node.arrange()
}
// Skip all node body/widget/title rendering. Vue overlay handles visuals.
return
}
const color = node.renderingColor
const bgcolor = node.renderingBgColor
@@ -5762,6 +5865,34 @@ export class LGraphCanvas
}
}
/**
* Build LinkRenderContext from canvas properties
* Helper method for using LitegraphLinkAdapter
*/
private buildLinkRenderContext(): LinkRenderContext {
return {
// Canvas settings
renderMode: this.links_render_mode,
connectionWidth: this.connections_width,
renderBorder: this.render_connections_border,
lowQuality: this.low_quality,
highQualityRender: this.highquality_render,
scale: this.ds.scale,
linkMarkerShape: this.linkMarkerShape,
renderConnectionArrows: this.render_connection_arrows,
// State
highlightedLinks: new Set(Object.keys(this.highlighted_links)),
// Colors
defaultLinkColor: this.default_link_color,
linkTypeColors: LGraphCanvas.link_type_colors,
// Pattern for disabled links
disabledPattern: this._pattern
}
}
/**
* draws a link between two points
* @param ctx Canvas 2D rendering context
@@ -5803,333 +5934,27 @@ export class LGraphCanvas
disabled?: boolean
} = {}
): void {
const linkColour =
link != null && this.highlighted_links[link.id]
? '#FFF'
: color ||
link?.color ||
(link?.type != null && LGraphCanvas.link_type_colors[link.type]) ||
this.default_link_color
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
const dist =
this.links_render_mode == LinkRenderType.SPLINE_LINK &&
(!endControl || !startControl)
? distance(a, b)
: 0
// TODO: Subline code below was inserted in the wrong place - should be before this statement
if (this.render_connections_border && !this.low_quality) {
ctx.lineWidth = this.connections_width + 4
}
ctx.lineJoin = 'round'
num_sublines ||= 1
if (num_sublines > 1) ctx.lineWidth = 0.5
// begin line shape
const path = new Path2D()
/** The link or reroute we're currently rendering */
const linkSegment = reroute ?? link
if (linkSegment) linkSegment.path = path
const innerA = LGraphCanvas.#lTempA
const innerB = LGraphCanvas.#lTempB
/** Reference to {@link reroute._pos} if present, or {@link link._pos} if present. Caches the centre point of the link. */
const pos: Point = linkSegment?._pos ?? [0, 0]
for (let i = 0; i < num_sublines; i++) {
const offsety = (i - (num_sublines - 1) * 0.5) * 5
innerA[0] = a[0]
innerA[1] = a[1]
innerB[0] = b[0]
innerB[1] = b[1]
if (this.links_render_mode == LinkRenderType.SPLINE_LINK) {
if (endControl) {
innerB[0] = b[0] + endControl[0]
innerB[1] = b[1] + endControl[1]
} else {
this.#addSplineOffset(innerB, endDir, dist)
if (this.linkRenderer) {
const context = this.buildLinkRenderContext()
this.linkRenderer.renderLinkDirect(
ctx,
a,
b,
link,
skip_border,
flow,
color,
start_dir,
end_dir,
context,
{
reroute,
startControl,
endControl,
num_sublines,
disabled
}
if (startControl) {
innerA[0] = a[0] + startControl[0]
innerA[1] = a[1] + startControl[1]
} else {
this.#addSplineOffset(innerA, startDir, dist)
}
path.moveTo(a[0], a[1] + offsety)
path.bezierCurveTo(
innerA[0],
innerA[1] + offsety,
innerB[0],
innerB[1] + offsety,
b[0],
b[1] + offsety
)
// Calculate centre point
findPointOnCurve(pos, a, b, innerA, innerB, 0.5)
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
const justPastCentre = LGraphCanvas.#lTempC
findPointOnCurve(justPastCentre, a, b, innerA, innerB, 0.51)
linkSegment._centreAngle = Math.atan2(
justPastCentre[1] - pos[1],
justPastCentre[0] - pos[0]
)
}
} else {
const l = this.links_render_mode == LinkRenderType.LINEAR_LINK ? 15 : 10
switch (startDir) {
case LinkDirection.LEFT:
innerA[0] += -l
break
case LinkDirection.RIGHT:
innerA[0] += l
break
case LinkDirection.UP:
innerA[1] += -l
break
case LinkDirection.DOWN:
innerA[1] += l
break
}
switch (endDir) {
case LinkDirection.LEFT:
innerB[0] += -l
break
case LinkDirection.RIGHT:
innerB[0] += l
break
case LinkDirection.UP:
innerB[1] += -l
break
case LinkDirection.DOWN:
innerB[1] += l
break
}
if (this.links_render_mode == LinkRenderType.LINEAR_LINK) {
path.moveTo(a[0], a[1] + offsety)
path.lineTo(innerA[0], innerA[1] + offsety)
path.lineTo(innerB[0], innerB[1] + offsety)
path.lineTo(b[0], b[1] + offsety)
// Calculate centre point
pos[0] = (innerA[0] + innerB[0]) * 0.5
pos[1] = (innerA[1] + innerB[1]) * 0.5
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
linkSegment._centreAngle = Math.atan2(
innerB[1] - innerA[1],
innerB[0] - innerA[0]
)
}
} else if (this.links_render_mode == LinkRenderType.STRAIGHT_LINK) {
const midX = (innerA[0] + innerB[0]) * 0.5
path.moveTo(a[0], a[1])
path.lineTo(innerA[0], innerA[1])
path.lineTo(midX, innerA[1])
path.lineTo(midX, innerB[1])
path.lineTo(innerB[0], innerB[1])
path.lineTo(b[0], b[1])
// Calculate centre point
pos[0] = midX
pos[1] = (innerA[1] + innerB[1]) * 0.5
if (linkSegment && this.linkMarkerShape === LinkMarkerShape.Arrow) {
const diff = innerB[1] - innerA[1]
if (Math.abs(diff) < 4) linkSegment._centreAngle = 0
else if (diff > 0) linkSegment._centreAngle = Math.PI * 0.5
else linkSegment._centreAngle = -(Math.PI * 0.5)
}
} else {
return
}
}
}
// rendering the outline of the connection can be a little bit slow
if (this.render_connections_border && !this.low_quality && !skip_border) {
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
ctx.stroke(path)
}
ctx.lineWidth = this.connections_width
ctx.fillStyle = ctx.strokeStyle = linkColour
ctx.stroke(path)
// render arrow in the middle
if (this.ds.scale >= 0.6 && this.highquality_render && linkSegment) {
// render arrow
if (this.render_connection_arrows) {
// compute two points in the connection
const posA = this.computeConnectionPoint(a, b, 0.25, startDir, endDir)
const posB = this.computeConnectionPoint(a, b, 0.26, startDir, endDir)
const posC = this.computeConnectionPoint(a, b, 0.75, startDir, endDir)
const posD = this.computeConnectionPoint(a, b, 0.76, startDir, endDir)
// compute the angle between them so the arrow points in the right direction
let angleA = 0
let angleB = 0
if (this.render_curved_connections) {
angleA = -Math.atan2(posB[0] - posA[0], posB[1] - posA[1])
angleB = -Math.atan2(posD[0] - posC[0], posD[1] - posC[1])
} else {
angleB = angleA = b[1] > a[1] ? 0 : Math.PI
}
// render arrow
const transform = ctx.getTransform()
ctx.translate(posA[0], posA[1])
ctx.rotate(angleA)
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
ctx.translate(posC[0], posC[1])
ctx.rotate(angleB)
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
}
// Draw link centre marker
ctx.beginPath()
if (this.linkMarkerShape === LinkMarkerShape.Arrow) {
const transform = ctx.getTransform()
ctx.translate(pos[0], pos[1])
if (linkSegment._centreAngle) ctx.rotate(linkSegment._centreAngle)
// The math is off, but it currently looks better in chromium
ctx.moveTo(-3.2, -5)
ctx.lineTo(+7, 0)
ctx.lineTo(-3.2, +5)
ctx.setTransform(transform)
} else if (
this.linkMarkerShape == null ||
this.linkMarkerShape === LinkMarkerShape.Circle
) {
ctx.arc(pos[0], pos[1], 5, 0, Math.PI * 2)
}
if (disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = this._pattern ?? '#797979'
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
}
ctx.fill()
if (LLink._drawDebug) {
const { fillStyle, font, globalAlpha, lineWidth, strokeStyle } = ctx
ctx.globalAlpha = 1
ctx.lineWidth = 4
ctx.fillStyle = 'white'
ctx.strokeStyle = 'black'
ctx.font = '16px Arial'
const text = String(linkSegment.id)
const { width, actualBoundingBoxAscent } = ctx.measureText(text)
const x = pos[0] - width * 0.5
const y = pos[1] + actualBoundingBoxAscent * 0.5
ctx.strokeText(text, x, y)
ctx.fillText(text, x, y)
ctx.font = font
ctx.globalAlpha = globalAlpha
ctx.lineWidth = lineWidth
ctx.fillStyle = fillStyle
ctx.strokeStyle = strokeStyle
}
}
// render flowing points
if (flow) {
ctx.fillStyle = linkColour
for (let i = 0; i < 5; ++i) {
const f = (LiteGraph.getTime() * 0.001 + i * 0.2) % 1
const flowPos = this.computeConnectionPoint(a, b, f, startDir, endDir)
ctx.beginPath()
ctx.arc(flowPos[0], flowPos[1], 5, 0, 2 * Math.PI)
ctx.fill()
}
}
}
/**
* Finds a point along a spline represented by a to b, with spline endpoint directions dictacted by start_dir and end_dir.
* @param a Start point
* @param b End point
* @param t Time: distance between points (e.g 0.25 is 25% along the line)
* @param start_dir Spline start direction
* @param end_dir Spline end direction
* @returns The point at {@link t} distance along the spline a-b.
*/
computeConnectionPoint(
a: ReadOnlyPoint,
b: ReadOnlyPoint,
t: number,
start_dir: LinkDirection,
end_dir: LinkDirection
): Point {
start_dir ||= LinkDirection.RIGHT
end_dir ||= LinkDirection.LEFT
const dist = distance(a, b)
const pa: Point = [a[0], a[1]]
const pb: Point = [b[0], b[1]]
this.#addSplineOffset(pa, start_dir, dist)
this.#addSplineOffset(pb, end_dir, dist)
const c1 = (1 - t) * (1 - t) * (1 - t)
const c2 = 3 * ((1 - t) * (1 - t)) * t
const c3 = 3 * (1 - t) * (t * t)
const c4 = t * t * t
const x = c1 * a[0] + c2 * pa[0] + c3 * pb[0] + c4 * b[0]
const y = c1 * a[1] + c2 * pa[1] + c3 * pb[1] + c4 * b[1]
return [x, y]
}
/**
* Modifies an existing point, adding a single-axis offset.
* @param point The point to add the offset to
* @param direction The direction to add the offset in
* @param dist Distance to offset
* @param factor Distance is mulitplied by this value. Default: 0.25
*/
#addSplineOffset(
point: Point,
direction: LinkDirection,
dist: number,
factor = 0.25
): void {
switch (direction) {
case LinkDirection.LEFT:
point[0] += dist * -factor
break
case LinkDirection.RIGHT:
point[0] += dist * factor
break
case LinkDirection.UP:
point[1] += dist * -factor
break
case LinkDirection.DOWN:
point[1] += dist * factor
break
)
}
}
@@ -6336,6 +6161,8 @@ export class LGraphCanvas
: segment.id
if (linkId !== undefined) {
graph.removeLink(linkId)
// Clean up layout store
layoutStore.deleteLinkLayout(linkId)
}
break
}
@@ -8413,11 +8240,22 @@ export class LGraphCanvas
// Check for reroutes
if (this.links_render_mode !== LinkRenderType.HIDDEN_LINK) {
const reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this.#visibleReroutes
)
// Try layout store first, fallback to old method
const rerouteLayout = layoutStore.queryRerouteAtPoint({
x: event.canvasX,
y: event.canvasY
})
let reroute: Reroute | undefined
if (rerouteLayout) {
reroute = this.graph.getReroute(rerouteLayout.id)
} else {
reroute = this.graph.getRerouteOnPos(
event.canvasX,
event.canvasY,
this.#visibleReroutes
)
}
if (reroute) {
menu_info.unshift(
{

View File

@@ -1,3 +1,13 @@
import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateInputSlotPosFromSlot,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { DragAndScale } from './DragAndScale'
import type { LGraph } from './LGraph'
import { BadgePosition, LGraphBadge } from './LGraphBadge'
@@ -258,6 +268,10 @@ export class LGraphNode
properties_info: INodePropertyInfo[] = []
flags: INodeFlags = {}
widgets?: IBaseWidget[]
/** Property manager for this node */
changeTracker: LGraphNodeProperties
/**
* The amount of space available for widgets to grow into.
* @see {@link layoutWidgets}
@@ -729,6 +743,37 @@ export class LGraphNode
error: this.#getErrorStrokeStyle,
selected: this.#getSelectedStrokeStyle
}
// Assign onMouseDown implementation
this.onMouseDown = (
// @ts-expect-error - CanvasPointerEvent type needs fixing
e: CanvasPointerEvent,
pos: Point,
canvas: LGraphCanvas
): boolean => {
// Check for title button clicks (only if not collapsed)
if (this.title_buttons?.length && !this.flags.collapsed) {
// pos contains the offset from the node's position, so we need to use node-relative coordinates
const nodeRelativeX = pos[0]
const nodeRelativeY = pos[1]
for (let i = 0; i < this.title_buttons.length; i++) {
const button = this.title_buttons[i]
if (
button.visible &&
button.isPointInside(nodeRelativeX, nodeRelativeY)
) {
this.onTitleButtonClick(button, canvas)
return true // Prevent default behavior
}
}
}
return false // Allow default behavior
}
// Initialize property manager with tracked properties
this.changeTracker = new LGraphNodeProperties(this)
}
/** Internal callback for subgraph nodes. Do not implement externally. */
@@ -1941,6 +1986,14 @@ export class LGraphNode
move(deltaX: number, deltaY: number): void {
if (this.pinned) return
// If Vue nodes mode is enabled, skip LiteGraph's direct position update
// The layout store will handle the movement and sync back to LiteGraph
if (LiteGraph.vueNodesMode) {
// Vue nodes handle their own dragging through the layout store
// This prevents the snap-back issue from conflicting position updates
return
}
this.pos[0] += deltaX
this.pos[1] += deltaY
}
@@ -2745,6 +2798,8 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2803,6 +2858,16 @@ export class LGraphNode
// add to graph links list
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
// connect in output
output.links ??= []
output.links.push(link.id)
@@ -3204,6 +3269,25 @@ export class LGraphNode
return this.outputs.filter((slot: INodeOutputSlot) => !slot.pos)
}
/**
* Get the context needed for slot position calculations
* @internal
*/
#getSlotPositionContext(): SlotPositionContext {
return {
nodeX: this.pos[0],
nodeY: this.pos[1],
nodeWidth: this.size[0],
nodeHeight: this.size[1],
collapsed: this.flags.collapsed ?? false,
collapsedWidth: this._collapsed_width,
slotStartY: this.constructor.slot_start_y,
inputs: this.inputs,
outputs: this.outputs,
widgets: this.widgets
}
}
/**
* Gets the position of an input slot, in graph co-ordinates.
*
@@ -3212,7 +3296,7 @@ export class LGraphNode
* @returns Position of the input slot
*/
getInputPos(slot: number): Point {
return this.getInputSlotPos(this.inputs[slot])
return calculateInputSlotPos(this.#getSlotPositionContext(), slot)
}
/**
@@ -3221,25 +3305,7 @@ export class LGraphNode
* @returns Position of the centre of the input slot in graph co-ordinates.
*/
getInputSlotPos(input: INodeInputSlot): Point {
const {
pos: [nodeX, nodeY]
} = this
if (this.flags.collapsed) {
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX, nodeY - halfTitle]
}
const { pos } = input
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
// default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = this.constructor.slot_start_y || 0
const slotIndex = this.#defaultVerticalInputs.indexOf(input)
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
return calculateInputSlotPosFromSlot(this.#getSlotPositionContext(), input)
}
/**
@@ -3250,29 +3316,7 @@ export class LGraphNode
* @returns Position of the output slot
*/
getOutputPos(slot: number): Point {
const {
pos: [nodeX, nodeY],
outputs,
size: [width]
} = this
if (this.flags.collapsed) {
const width = this._collapsed_width || LiteGraph.NODE_COLLAPSED_WIDTH
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX + width, nodeY - halfTitle]
}
const outputPos = outputs?.[slot]?.pos
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
// default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = this.constructor.slot_start_y || 0
const slotIndex = this.#defaultVerticalOutputs.indexOf(this.outputs[slot])
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
// TODO: Why +1?
return [nodeX + width + 1 - offsetX, nodeY + slotY + nodeOffsetY]
return calculateOutputSlotPos(this.#getSlotPositionContext(), slot)
}
/** @inheritdoc */
@@ -3818,12 +3862,33 @@ export class LGraphNode
? this.getInputPos(slotIndex)
: this.getOutputPos(slotIndex)
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
if (LiteGraph.vueNodesMode) {
// Vue-based slot dimensions
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
if (slot.isWidgetInputSlot) {
// Widget slots have a 20x20 clickable area centered at the position
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - 10
slot.boundingRect[2] = 20
slot.boundingRect[3] = 20
} else {
// Regular slots have a 20x20 clickable area for the connector
// but the full slot height for vertical spacing
slot.boundingRect[0] = pos[0] - 10
slot.boundingRect[1] = pos[1] - dimensions.SLOT_HEIGHT / 2
slot.boundingRect[2] = 20
slot.boundingRect[3] = dimensions.SLOT_HEIGHT
}
} else {
// Traditional LiteGraph dimensions
slot.boundingRect[0] = pos[0] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[1] = pos[1] - LiteGraph.NODE_SLOT_HEIGHT * 0.5
slot.boundingRect[2] = slot.isWidgetInputSlot
? BaseWidget.margin
: LiteGraph.NODE_SLOT_HEIGHT
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
}
}
#measureSlots(): ReadOnlyRect | null {
@@ -4019,14 +4084,26 @@ export class LGraphNode
}
if (!slotByWidgetName.size) return
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
// Only set custom pos if not using Vue positioning
// Vue positioning calculates widget slot positions dynamically
if (!LiteGraph.vueNodesMode) {
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
const actualSlot = this.#concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this.#measureSlot(actualSlot, slot.index, true)
const actualSlot = this.#concreteInputs[slot.index]
const offset = LiteGraph.NODE_SLOT_HEIGHT * 0.5
actualSlot.pos = [offset, widget.y + offset]
this.#measureSlot(actualSlot, slot.index, true)
}
} else {
// For Vue positioning, just measure the slots without setting pos
for (const widget of this.widgets) {
const slot = slotByWidgetName.get(widget.name)
if (!slot) continue
this.#measureSlot(this.#concreteInputs[slot.index], slot.index, true)
}
}
}

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

@@ -2,6 +2,8 @@ import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
} from '@/lib/litegraph/src/constants'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LGraphNode, NodeId } from './LGraphNode'
import type { Reroute, RerouteId } from './Reroute'
@@ -14,13 +16,14 @@ import type {
LinkSegment,
ReadonlyLinkNetwork
} from './interfaces'
import { Subgraph } from './litegraph'
import type {
Serialisable,
SerialisableLLink,
SubgraphIO
} from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type LinkId = number
export type SerialisedLLinkArray = [
@@ -460,19 +463,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network.reroutes.delete(reroute.id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
}
}
network.links.delete(this.id)
if (this.originIsIoNode && network instanceof Subgraph) {
const subgraphInput = network.inputs.at(this.origin_slot)
if (!subgraphInput)
throw new Error('Invalid link - subgraph input not found')
subgraphInput.events.dispatch('input-disconnected', {
input: subgraphInput
})
}
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**

View File

@@ -24,6 +24,26 @@ import {
} from './types/globalEnums'
import { createUuidv4 } from './utils/uuid'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*
* IMPORTANT: These values must match the actual rendered dimensions of Vue components
* for the positioning contract to work correctly.
*/
export const COMFY_VUE_NODE_DIMENSIONS = {
spacing: {
BETWEEN_SLOTS_AND_BODY: 8,
BETWEEN_WIDGETS: 8
},
components: {
HEADER_HEIGHT: 34, // 18 header + 16 padding
SLOT_HEIGHT: 24,
STANDARD_WIDGET_HEIGHT: 30
}
} as const
/**
* The Global Scope. It contains all the registered node classes.
*/
@@ -75,6 +95,14 @@ export class LiteGraphGlobal {
WIDGET_SECONDARY_TEXT_COLOR = '#999'
WIDGET_DISABLED_TEXT_COLOR = '#666'
/**
* Vue node dimensions configuration for the contract between LiteGraph and Vue components.
* These values ensure both systems can independently calculate node, slot, and widget positions
* to place them in identical locations.
*/
// WARNING THIS WILL BE REMOVED IN FAVOR OF THE SLOTS LAYOUT TREE useDomSlotRegistration
COMFY_VUE_NODE_DIMENSIONS = COMFY_VUE_NODE_DIMENSIONS
LINK_COLOR = '#9A9'
EVENT_LINK_COLOR = '#A86'
CONNECTING_LINK_COLOR = '#AFA'
@@ -330,6 +358,18 @@ export class LiteGraphGlobal {
*/
saveViewportWithGraph: boolean = true
/**
* Enable Vue nodes mode for rendering and positioning.
* When true:
* - Nodes will calculate slot positions using Vue component dimensions
* - LiteGraph will skip rendering node bodies entirely
* - Vue components will handle all node rendering
* - LiteGraph continues to render connections, links, and graph background
* This should be set by the frontend when the Vue nodes feature is enabled.
* @default false
*/
vueNodesMode: boolean = false
// TODO: Remove legacy accessors
LGraph = LGraph
LLink = LLink

View File

@@ -1,3 +1,6 @@
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraphNode, NodeId } from './LGraphNode'
import { LLink, type LinkId } from './LLink'
@@ -15,6 +18,8 @@ import type {
import { distance, isPointInRect } from './measure'
import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type RerouteId = number
/** The input or output slot that an incomplete reroute link is connected to. */
@@ -407,8 +412,17 @@ export class Reroute
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
const previousPos = { x: this.#pos[0], y: this.#pos[1] }
this.#pos[0] += deltaX
this.#pos[1] += deltaY
// Update Layout Store with new position
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(
this.id,
{ x: this.#pos[0], y: this.#pos[1] },
previousPos
)
}
/** @inheritdoc */

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()
@@ -134,7 +134,8 @@ export {
} from './LGraphBadge'
export { LGraphCanvas, type LGraphCanvasState } from './LGraphCanvas'
export { LGraphGroup } from './LGraphGroup'
export { LGraphNode, type NodeId } from './LGraphNode'
export { LGraphNode, type NodeId, type NodeProperty } from './LGraphNode'
export { COMFY_VUE_NODE_DIMENSIONS } from './LiteGraphGlobal'
export { type LinkId, LLink } from './LLink'
export { createBounds } from './measure'
export { Reroute, type RerouteId } from './Reroute'

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

@@ -17,12 +17,22 @@ import { toClass } from '@/lib/litegraph/src/utils/type'
import { BaseWidget } from './BaseWidget'
import { BooleanWidget } from './BooleanWidget'
import { ButtonWidget } from './ButtonWidget'
import { ChartWidget } from './ChartWidget'
import { ColorWidget } from './ColorWidget'
import { ComboWidget } from './ComboWidget'
import { FileUploadWidget } from './FileUploadWidget'
import { GalleriaWidget } from './GalleriaWidget'
import { ImageCompareWidget } from './ImageCompareWidget'
import { KnobWidget } from './KnobWidget'
import { LegacyWidget } from './LegacyWidget'
import { MarkdownWidget } from './MarkdownWidget'
import { MultiSelectWidget } from './MultiSelectWidget'
import { NumberWidget } from './NumberWidget'
import { SelectButtonWidget } from './SelectButtonWidget'
import { SliderWidget } from './SliderWidget'
import { TextWidget } from './TextWidget'
import { TextareaWidget } from './TextareaWidget'
import { TreeSelectWidget } from './TreeSelectWidget'
export type WidgetTypeMap = {
button: ButtonWidget
@@ -34,6 +44,16 @@ export type WidgetTypeMap = {
string: TextWidget
text: TextWidget
custom: LegacyWidget
fileupload: FileUploadWidget
color: ColorWidget
markdown: MarkdownWidget
treeselect: TreeSelectWidget
multiselect: MultiSelectWidget
chart: ChartWidget
galleria: GalleriaWidget
imagecompare: ImageCompareWidget
selectbutton: SelectButtonWidget
textarea: TextareaWidget
[key: string]: BaseWidget
}
@@ -82,6 +102,26 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
return toClass(TextWidget, narrowedWidget, node)
case 'text':
return toClass(TextWidget, narrowedWidget, node)
case 'fileupload':
return toClass(FileUploadWidget, narrowedWidget, node)
case 'color':
return toClass(ColorWidget, narrowedWidget, node)
case 'markdown':
return toClass(MarkdownWidget, narrowedWidget, node)
case 'treeselect':
return toClass(TreeSelectWidget, narrowedWidget, node)
case 'multiselect':
return toClass(MultiSelectWidget, narrowedWidget, node)
case 'chart':
return toClass(ChartWidget, narrowedWidget, node)
case 'galleria':
return toClass(GalleriaWidget, narrowedWidget, node)
case 'imagecompare':
return toClass(ImageCompareWidget, narrowedWidget, node)
case 'selectbutton':
return toClass(SelectButtonWidget, narrowedWidget, node)
case 'textarea':
return toClass(TextareaWidget, narrowedWidget, node)
default: {
if (wrapLegacyWidgets) return toClass(LegacyWidget, widget, node)
}

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

@@ -326,3 +326,331 @@ LGraph {
"version": 1,
}
`;
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredBasicGraph 1`] = `
LGraph {
"_groups": [
LGraphGroup {
"_bounding": Float32Array [
20,
20,
1,
3,
],
"_children": Set {},
"_nodes": [],
"_pos": Float32Array [
20,
20,
],
"_size": Float32Array [
1,
3,
],
"color": "#6029aa",
"flags": {},
"font": undefined,
"font_size": 14,
"graph": [Circular],
"id": 123,
"isPointInside": [Function],
"selected": undefined,
"setDirtyCanvas": [Function],
"title": "A group to test with",
},
],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_nodes_by_id": {
"1": LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
},
"_nodes_executable": [],
"_nodes_in_order": [
LGraphNode {
"_collapsed_width": undefined,
"_level": undefined,
"_pos": Float32Array [
10,
10,
],
"_posSize": Float32Array [
10,
10,
140,
60,
],
"_relative_id": undefined,
"_shape": undefined,
"_size": Float32Array [
140,
60,
],
"action_call": undefined,
"action_triggered": undefined,
"badgePosition": "top-left",
"badges": [],
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
"console": undefined,
"exec_version": undefined,
"execute_triggered": undefined,
"flags": {},
"freeWidgetSpace": undefined,
"gotFocusAt": undefined,
"graph": [Circular],
"has_errors": undefined,
"id": 1,
"ignore_remove": undefined,
"inputs": [],
"last_serialization": undefined,
"locked": undefined,
"lostFocusAt": undefined,
"mode": 0,
"mouseOver": undefined,
"order": 0,
"outputs": [],
"progress": undefined,
"properties": {},
"properties_info": [],
"redraw_on_mouse": undefined,
"removable": undefined,
"resizable": undefined,
"selected": undefined,
"serialize_widgets": undefined,
"showAdvanced": undefined,
"strokeStyles": {
"error": [Function],
"selected": [Function],
},
"title": "LGraphNode",
"type": "mustBeSet",
"widgets": undefined,
"widgets_start_y": undefined,
"widgets_up": undefined,
},
],
"_subgraphs": Map {},
"_version": 3,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "ca9da7d8-fddd-4707-ad32-67be9be13140",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 123,
"lastLinkId": 0,
"lastNodeId": 1,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;
exports[`LGraph configure() > LGraph matches previous snapshot (normal configure() usage) > configuredMinGraph 1`] = `
LGraph {
"_groups": [],
"_input_nodes": undefined,
"_last_trigger_time": undefined,
"_links": Map {},
"_nodes": [],
"_nodes_by_id": {},
"_nodes_executable": [],
"_nodes_in_order": [],
"_subgraphs": Map {},
"_version": 0,
"catch_errors": true,
"config": {},
"elapsed_time": 0.01,
"errors_in_execution": undefined,
"events": CustomEventTarget {},
"execution_time": undefined,
"execution_timer_id": undefined,
"extra": {},
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"globaltime": 0,
"id": "d175890f-716a-4ece-ba33-1d17a513b7be",
"iteration": 0,
"last_update_time": 0,
"links": Map {},
"list_of_graphcanvas": null,
"nodes_actioning": [],
"nodes_executedAction": [],
"nodes_executing": [],
"revision": 0,
"runningtime": 0,
"starttime": 0,
"state": {
"lastGroupId": 0,
"lastLinkId": 0,
"lastNodeId": 0,
"lastRerouteId": 0,
},
"status": 1,
"vars": {},
"version": 1,
}
`;

View File

@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -133,6 +134,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -205,6 +207,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,

View File

@@ -62,6 +62,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -131,6 +132,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,
@@ -201,6 +203,7 @@ LGraph {
"bgcolor": undefined,
"block_delete": undefined,
"boxcolor": undefined,
"changeTracker": undefined,
"clip_area": undefined,
"clonable": undefined,
"color": undefined,