Compare commits

...

8 Commits

Author SHA1 Message Date
Christian Byrne
d4916fa999 Change monorepo conversion ADR status to accepted (#5364) 2025-09-04 22:47:02 -07:00
Christian Byrne
a49cbab684 [docs] ADR: Centralized Layout Management with CRDT (#5233)
Adds ADR-0004 documenting the architectural decision to implement centralized layout management using CRDT backing store with command pattern architecture.

## Key Technical Decisions Documented

- **Centralized State Management**: Move from scattered `node.position` mutations to single authoritative layout store
- **CRDT Foundation**: Yjs-backed store provides conflict resolution and collaboration readiness
- **Command Pattern**: All spatial mutations flow through explicit commands for undo/redo and system coordination
- **Reactive Architecture**: Transition from O(n) diff-based change detection to O(1) signal-based reactivity

## Current Architecture Problems Addressed

- Performance bottlenecks from polling-based change detection in complex workflows
- Position conflicts between LiteGraph canvas and DOMwidgets.ts overlay systems
- Inability to support collaborative editing due to direct mutation patterns
- Renderer lock-in preventing alternative rendering backends
2025-09-04 22:32:06 -07:00
Alexander Brown
3fbcf4aa7e knip: YOLO pass, all the unused exports enabled, YAGNI for the rest (#5313)
* knip: Enable unusedBinaries, add two exceptions

* knip: YOLO pass, all the unused exports enabled.
Paired with @christian-byrne to allow for some special cases to remain with custom knip ignore tags.

* knip: remove post-rebase
2025-09-04 22:29:44 -07:00
Christian Byrne
006e6bd57c [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>
2025-09-04 21:31:59 -07:00
Alexander Brown
58313ea05b devex: re-add caching of playwright browser installs (#5350) 2025-09-04 12:18:11 -07:00
filtered
ae690bbfa4 DevX: Update CSS directives for Tailwind v4 (#5347)
- Provides proper IDE hints and removes warnings from .vue SFC files.
- Replaces all the v2 info with v4.
2025-09-04 10:32:22 -07:00
filtered
9920f310be [CI] Fix action throws after pnpm change (#5342) 2025-09-04 00:27:05 -07:00
Comfy Org PR Bot
ec7de6f4c5 [release] Increment version to 1.27.0 (#5343)
Co-authored-by: benceruleanlu <162923238+benceruleanlu@users.noreply.github.com>
2025-09-03 23:58:58 -07:00
205 changed files with 18283 additions and 1471 deletions

View File

@@ -12,6 +12,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
playwright-version: ${{ steps.playwright-version.outputs.PLAYWRIGHT_VERSION }}
steps:
- name: Checkout ComfyUI
uses: actions/checkout@v4
@@ -66,6 +67,13 @@ jobs:
id: cache-key
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
- name: Playwright Version
id: playwright-version
run: |
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --json | jq --raw-output '.[0].devDependencies["@playwright/test"].version')
echo "PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
working-directory: ComfyUI_frontend
- name: Save cache
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
with:
@@ -116,16 +124,30 @@ jobs:
pip install wait-for-it
working-directory: ComfyUI
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: cache-playwright-browsers
with:
path: '~/.cache/ms-playwright'
key: '${{ runner.os }}-playwright-browsers-${{ needs.setup.outputs.playwright-version }}'
- name: Install Playwright Browsers
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Install Playwright Browsers (operating system dependencies)
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps
working-directory: ComfyUI_frontend
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
id: playwright
run: npx playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
@@ -141,7 +163,7 @@ jobs:
retention-days: 1
playwright-tests:
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
# Ideally, each shard runs test in 6 minutes, but allow up to 15 minutes
timeout-minutes: 15
needs: setup
runs-on: ubuntu-latest
@@ -182,16 +204,29 @@ jobs:
pip install wait-for-it
working-directory: ComfyUI
- name: Cache Playwright Browsers
uses: actions/cache@v4
id: cache-playwright-browsers
with:
path: '~/.cache/ms-playwright'
key: '${{ runner.os }}-playwright-browsers-${{ needs.setup.outputs.playwright-version }}'
- name: Install Playwright Browsers
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Install Playwright Browsers (operating system dependencies)
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps
working-directory: ComfyUI_frontend
- name: Start ComfyUI server
run: |
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
wait-for-it --service 127.0.0.1:8188 -t 600
working-directory: ComfyUI
- name: Install Playwright Browsers
run: npx playwright install chromium --with-deps
working-directory: ComfyUI_frontend
- name: Run Playwright tests (${{ matrix.browser }})
id: playwright
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
@@ -248,4 +283,4 @@ jobs:
with:
name: playwright-report-chromium
path: ComfyUI_frontend/playwright-report/
retention-days: 30
retention-days: 30

View File

@@ -40,7 +40,7 @@ jobs:
- name: Get new version
id: get-version
run: |
NEW_VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('./pnpm-lock.yaml')).packages['node_modules/@comfyorg/comfyui-electron-types'].version)")
NEW_VERSION=$(pnpm list @comfyorg/comfyui-electron-types --json --depth=0 | jq -r '.[0].version')
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request

44
.vscode/tailwind.json vendored
View File

@@ -2,12 +2,32 @@
"version": 1.1,
"atDirectives": [
{
"name": "@tailwind",
"description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.",
"name": "@import",
"description": "Use the `@import` directive to inline CSS files, including Tailwind itself, into your stylesheet.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#tailwind"
"url": "https://tailwindcss.com/docs/functions-and-directives#import"
}
]
},
{
"name": "@theme",
"description": "Use the `@theme` directive to define custom design tokens like fonts, colors, and breakpoints.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#theme"
}
]
},
{
"name": "@layer",
"description": "Use the `@layer` directive inside `@theme` to organize custom styles into different layers like `base`, `components`, and `utilities`.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#layer"
}
]
},
@@ -22,32 +42,32 @@
]
},
{
"name": "@responsive",
"description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n",
"name": "@config",
"description": "Use the `@config` directive to load a legacy JavaScript-based Tailwind configuration file.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#responsive"
"url": "https://tailwindcss.com/docs/functions-and-directives#config"
}
]
},
{
"name": "@screen",
"description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n",
"name": "@reference",
"description": "Use the `@reference` directive to import theme variables, custom utilities, and custom variants from other files without duplicating CSS.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#screen"
"url": "https://tailwindcss.com/docs/functions-and-directives#reference"
}
]
},
{
"name": "@variants",
"description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n",
"name": "@plugin",
"description": "Use the `@plugin` directive to load a legacy JavaScript-based Tailwind plugin.",
"references": [
{
"name": "Tailwind Documentation",
"url": "https://tailwindcss.com/docs/functions-and-directives#variants"
"url": "https://tailwindcss.com/docs/functions-and-directives#plugin"
}
]
}

View File

@@ -127,3 +127,6 @@ const value = api.getServerFeature('config_name', defaultValue) // Get config
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER use `dark:` prefix - always use `dark-theme:` for dark mode styles, for example: `dark-theme:text-white dark-theme:bg-black`
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('bg-red-500', { 'bg-blue-500': condition })" />`

View File

@@ -0,0 +1,131 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeReference } from './litegraphUtils'
/**
* VueNodeFixture provides Vue-specific testing utilities for interacting with
* Vue node components. It bridges the gap between litegraph node references
* and Vue UI components.
*/
export class VueNodeFixture {
constructor(
private readonly nodeRef: NodeReference,
private readonly page: Page
) {}
/**
* Get the node's header element using data-testid
*/
async getHeader(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-header-${nodeId}"]`)
}
/**
* Get the node's title element
*/
async getTitleElement(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-title"]')
}
/**
* Get the current title text
*/
async getTitle(): Promise<string> {
const titleElement = await this.getTitleElement()
return (await titleElement.textContent()) || ''
}
/**
* Set a new title by double-clicking and entering text
*/
async setTitle(newTitle: string): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill(newTitle)
await input.press('Enter')
}
/**
* Cancel title editing
*/
async cancelTitleEdit(): Promise<void> {
const titleElement = await this.getTitleElement()
await titleElement.dblclick()
const input = (await this.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.press('Escape')
}
/**
* Check if the title is currently being edited
*/
async isEditingTitle(): Promise<boolean> {
const header = await this.getHeader()
const input = header.locator('[data-testid="node-title-input"]')
return await input.isVisible()
}
/**
* Get the collapse/expand button
*/
async getCollapseButton(): Promise<Locator> {
const header = await this.getHeader()
return header.locator('[data-testid="node-collapse-button"]')
}
/**
* Toggle the node's collapsed state
*/
async toggleCollapse(): Promise<void> {
const button = await this.getCollapseButton()
await button.click()
}
/**
* Get the collapse icon element
*/
async getCollapseIcon(): Promise<Locator> {
const button = await this.getCollapseButton()
return button.locator('i')
}
/**
* Get the collapse icon's CSS classes
*/
async getCollapseIconClass(): Promise<string> {
const icon = await this.getCollapseIcon()
return (await icon.getAttribute('class')) || ''
}
/**
* Check if the collapse button is visible
*/
async isCollapseButtonVisible(): Promise<boolean> {
const button = await this.getCollapseButton()
return await button.isVisible()
}
/**
* Get the node's body/content element
*/
async getBody(): Promise<Locator> {
const nodeId = this.nodeRef.id
return this.page.locator(`[data-testid="node-body-${nodeId}"]`)
}
/**
* Check if the node body is visible (not collapsed)
*/
async isBodyVisible(): Promise<boolean> {
const body = await this.getBody()
return await body.isVisible()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 177 KiB

View File

@@ -0,0 +1,134 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
test.describe('NodeHeader', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('displays node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('allows title renaming', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('handles node collapsing', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Initially should not be collapsed
expect(await node.isCollapsed()).toBe(false)
const body = await vueNode.getBody()
await expect(body).toBeVisible()
// Collapse the node
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(true)
// Verify node content is hidden
const collapsedSize = await node.getSize()
await expect(body).not.toBeVisible()
// Expand again
await vueNode.toggleCollapse()
expect(await node.isCollapsed()).toBe(false)
await expect(body).toBeVisible()
// Size should be restored
const expandedSize = await node.getSize()
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
})
test('shows collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Check initial expanded state icon
let iconClass = await vueNode.getCollapseIconClass()
expect(iconClass).toContain('pi-chevron-down')
// Collapse and check icon
await vueNode.toggleCollapse()
iconClass = await vueNode.getCollapseIconClass()
expect(iconClass).toContain('pi-chevron-right')
// Expand and check icon
await vueNode.toggleCollapse()
iconClass = await vueNode.getCollapseIconClass()
expect(iconClass).toContain('pi-chevron-down')
})
test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Set custom title
await vueNode.setTitle('Test Sampler')
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Collapse
await vueNode.toggleCollapse()
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Expand
await vueNode.toggleCollapse()
expect(await vueNode.getTitle()).toBe('Test Sampler')
// Verify title is still displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('Test Sampler')
})
})

View File

@@ -0,0 +1,156 @@
# 4. Centralized Layout Management with CRDT
Date: 2025-08-27
## Status
Proposed
## Context
ComfyUI's node graph editor currently suffers from fundamental architectural limitations around spatial data management that prevent us from achieving key product goals.
### Current Architecture Problems
The existing system allows each node to directly mutate its position within LiteGraph's canvas renderer. This creates several critical issues:
1. **Performance Bottlenecks**: UI updates require full graph traversals to detect position changes. Large workflows (100+ nodes) can create bottlenecks during interactions due to this O(n) polling approach.
2. **Position Conflicts**: Multiple systems (LiteGraph canvas, DOMwidgets.ts overlays) currently compete to control node positions. Future Vue widget overlays will compound this maintenance burden.
3. **No Collaboration Foundation**: Direct position mutations make concurrent editing impossible—there's no mechanism to merge conflicting position updates from multiple users.
4. **Renderer Lock-in**: Spatial data is tightly coupled to LiteGraph's canvas implementation, preventing alternative rendering approaches (WebGL, DOM, other libraries, hybrid approaches).
5. **Inefficient Change Detection**: While LiteGraph provides some events, many operations require polling via changeTracker.ts. The current undo/redo system performs expensive diffs on every interaction rather than using reactive push/pull signals, creating performance bottlenecks and blocking efficient animations and viewport culling.
This represents a fundamental architectural limitation: diff-based systems scale O(n) with graph complexity (traverse entire structure to detect changes), while signal-based reactive systems scale O(1) with actual changes (data mutations automatically notify subscribers). Modern frameworks (Vue 3, Angular signals, SolidJS) have moved to reactive approaches for precisely this performance reason.
### Business Context
- Performance issues emerge with workflow complexity (100+ nodes)
- The AI workflow community increasingly expects collaborative features (similar to Figma, Miro)
- Accessibility requirements will necessitate DOM-based rendering options
- Technical debt compounds with each new spatial feature
This decision builds on [ADR-0001 (Merge LiteGraph)](0001-merge-litegraph-into-frontend.md), which enables the architectural restructuring proposed here.
## Decision
We will implement a centralized layout management system using CRDT (Conflict-free Replicated Data Types) with command pattern architecture to separate spatial data from rendering behavior.
### Centralized State Management Foundation
This solution applies proven centralized state management patterns:
- **Centralized Store**: All spatial data (position, size, bounds, transform) managed in a single CRDT-backed store
- **Command Interface**: All mutations flow through explicit commands rather than direct property access
- **Observer Pattern**: Independent systems (rendering, interaction, layout) subscribe to state changes
- **Domain Separation**: Layout logic completely separated from rendering and UI concerns
This provides single source of truth, predictable state updates, and natural system decoupling—solving our core architectural problems.
### Core Architecture
1. **Centralized Layout Store**: A Yjs CRDT maintains all spatial data in a single authoritative store:
```typescript
// Instead of: node.position = {x, y}
layoutStore.moveNode(nodeId, {x, y})
```
2. **Command Pattern**: All spatial mutations flow through explicit commands:
```
User Input → Commands → Layout Store → Observer Notifications → Renderers
```
3. **Observer-Based Systems**: Multiple independent systems subscribe to layout changes:
- **Rendering Systems**: LiteGraph canvas, WebGL, DOM accessibility renderers
- **Interaction Systems**: Drag handlers, selection, hover states
- **Layout Systems**: Auto-layout, alignment, distribution
- **Animation Systems**: Smooth transitions, physics simulations
4. **Reactive Updates**: Store changes propagate through observers, eliminating polling and enabling efficient system coordination.
### Implementation Strategy
**Phase 1: Parallel System**
- Build CRDT layout store alongside existing system
- Layout store initially mirrors LiteGraph changes via observers
- Gradually migrate user interactions to use command interface
- Maintain full backward compatibility
**Phase 2: Inversion of Control**
- CRDT store becomes single source of truth
- LiteGraph receives position updates via reactive subscriptions
- Enable alternative renderers and advanced features
### Why Centralized State + CRDT?
This combination provides both architectural and technical benefits:
**Centralized State Benefits:**
- **Single Source of Truth**: All layout data managed in one place, eliminating conflicts
- **System Decoupling**: Rendering, interaction, and layout systems operate independently
- **Predictable Updates**: Clear data flow makes debugging and testing easier
- **Extensibility**: Easy to add new layout behaviors without modifying existing systems
**CRDT Benefits:**
- **Conflict Resolution**: Automatic merging eliminates position conflicts between systems
- **Collaboration-Ready**: Built-in support for multi-user editing
- **Eventual Consistency**: Guaranteed convergence to same state across all clients
**Yjs-Specific Benefits:**
- **Event-Driven**: Native observer pattern removes need for polling
- **Selective Updates**: Only changed nodes trigger system updates
- **Fine-Grained Changes**: Efficient delta synchronization
## Consequences
### Positive
- **Eliminates Polling**: Observer pattern removes O(n) graph traversals, improving performance
- **System Modularity**: Independent systems can be developed, tested, and optimized separately
- **Renderer Flexibility**: Easy to add WebGL, DOM accessibility, or hybrid rendering systems
- **Rich Interactions**: Command pattern enables robust undo/redo, macros, and interaction history
- **Collaboration-Ready**: CRDT foundation enables real-time multi-user editing
- **Conflict Resolution**: Eliminates position "snap-back" behavior between competing systems
- **Better Developer Experience**: Clear separation of concerns and predictable data flow patterns
### Negative
- **Learning Curve**: Team must understand CRDT concepts and centralized state management
- **Migration Complexity**: Gradual migration of existing direct property access requires careful coordination
- **Memory Overhead**: Yjs library (~30KB) plus operation history storage
- **CRDT Performance**: CRDTs have computational overhead compared to direct property access
- **Increased Abstraction**: Additional layer between user interactions and visual updates
### Risk Mitigations
- Provide comprehensive migration documentation and examples
- Build compatibility layer for gradual, low-risk migration
- Implement operation history pruning for long-running sessions
- Phase implementation to validate approach before full migration
## Notes
This centralized state + CRDT architecture follows patterns from modern collaborative applications:
**Centralized State Management**: Similar to Redux/Vuex patterns in complex web applications, but with CRDT backing for collaboration. This provides predictable state updates while enabling real-time multi-user features.
**CRDT in Collaboration**: Tools like Figma, Linear, and Notion use similar approaches for real-time collaboration, demonstrating the effectiveness of separating authoritative data from presentation logic.
**Future Capabilities**: This foundation enables advanced features that would be difficult with the current architecture:
- Macro recording and workflow automation
- Programmatic layout optimization and constraints
- API-driven workflow construction
- Multiple simultaneous renderers (canvas + accessibility DOM)
- Real-time collaborative editing
- Advanced spatial features (physics, animations, auto-layout)
The architecture provides immediate single-user benefits while creating infrastructure for collaborative and advanced spatial features.
## References
- [Yjs Documentation](https://docs.yjs.dev/)
- [CRDTs: The Hard Parts](https://martin.kleppmann.com/2020/07/06/crdt-hard-parts-hydra.html) by Martin Kleppmann
- [Figma's Multiplayer Technology](https://www.figma.com/blog/how-figmas-multiplayer-technology-works/)

View File

@@ -11,7 +11,7 @@ An Architecture Decision Record captures an important architectural decision mad
| ADR | Title | Status | Date |
|-----|-------|--------|------|
| [0001](0001-merge-litegraph-into-frontend.md) | Merge LiteGraph.js into ComfyUI Frontend | Accepted | 2025-08-05 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Proposed | 2025-08-25 |
| [0002](0002-monorepo-conversion.md) | Restructure as a Monorepo | Accepted | 2025-08-25 |
## Creating a New ADR
@@ -77,4 +77,4 @@ Optional section for additional information, references, or clarifications.
## Further Reading
- [Documenting Architecture Decisions](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) by Michael Nygard
- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources
- [Architecture Decision Records](https://adr.github.io/) - Collection of ADR resources

View File

@@ -16,6 +16,7 @@ const config: KnipConfig = {
'tests-ui/**/*.{js,ts,vue}',
'*.{js,ts,mts}'
],
ignoreBinaries: ['only-allow', 'openapi-typescript'],
ignoreDependencies: [
'@primeuix/forms',
'@primeuix/styled',
@@ -59,7 +60,11 @@ const config: KnipConfig = {
'src/components/button/TextButton.vue',
'src/components/card/CardTitle.vue',
'src/components/card/CardDescription.vue',
'src/components/input/SingleSelect.vue'
'src/components/input/SingleSelect.vue',
// Used by a custom node (that should move off of this)
'src/scripts/ui/components/splitButton.ts',
// Generated file: openapi
'src/types/comfyRegistryTypes.ts'
],
ignoreExportsUsedInFile: true,
// Vue-specific configuration
@@ -68,15 +73,12 @@ const config: KnipConfig = {
// Only check for unused files, disable all other rules
// TODO: Gradually enable other rules - see https://github.com/Comfy-Org/ComfyUI_frontend/issues/4888
rules: {
binaries: 'off',
classMembers: 'off',
duplicates: 'off',
enumMembers: 'off',
exports: 'off',
nsExports: 'off',
nsTypes: 'off',
types: 'off'
classMembers: 'off'
},
tags: [
'-knipIgnoreUnusedButUsedByCustomNodes',
'-knipIgnoreUnusedButUsedByVueNodesBranch'
],
// Include dependencies analysis
includeEntryExports: true
}

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.26.9",
"version": "1.27.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -124,6 +124,8 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"chart.js": "^4.5.0",
"clsx": "^2.1.1",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"es-toolkit": "^1.39.9",
@@ -140,12 +142,14 @@
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
"semver": "^7.7.2",
"tailwind-merge": "^3.3.1",
"three": "^0.170.0",
"tiptap-markdown": "^0.8.10",
"vue": "^3.5.13",
"vue-i18n": "^9.14.3",
"vue-router": "^4.4.3",
"vuefire": "^3.2.1",
"yjs": "^13.6.27",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}

58
pnpm-lock.yaml generated
View File

@@ -83,6 +83,12 @@ importers:
axios:
specifier: ^1.8.2
version: 1.11.0
chart.js:
specifier: ^4.5.0
version: 4.5.0
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.2.5
version: 3.2.5
@@ -131,6 +137,9 @@ importers:
semver:
specifier: ^7.7.2
version: 7.7.2
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
three:
specifier: ^0.170.0
version: 0.170.0
@@ -149,6 +158,9 @@ importers:
vuefire:
specifier: ^3.2.1
version: 3.2.1(consola@3.4.2)(firebase@11.6.0)(vue@3.5.13(typescript@5.9.2))
yjs:
specifier: ^13.6.27
version: 13.6.27
zod:
specifier: ^3.23.8
version: 3.24.1
@@ -1707,6 +1719,9 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@kurkle/color@0.3.4':
resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==}
'@lobehub/cli-ui@1.13.0':
resolution: {integrity: sha512-7kXm84dc6yiniEFb/KRZv5H4g43n+xKTSpKSczlv54DY3tHSuZjBARyI/UDxFVgn7ezWYAIFuphzs0hSdhs6hw==}
engines: {node: '>=18'}
@@ -3290,6 +3305,10 @@ packages:
charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
chart.js@4.5.0:
resolution: {integrity: sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==}
engines: {pnpm: '>=8'}
check-error@2.1.1:
resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==}
engines: {node: '>= 16'}
@@ -3342,6 +3361,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
code-excerpt@4.0.0:
resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -4540,6 +4563,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic.js@0.2.5:
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
istanbul-lib-coverage@3.2.2:
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
engines: {node: '>=8'}
@@ -4726,6 +4752,11 @@ packages:
resolution: {integrity: sha512-vzaalVBmFLnMaedq0QAsBAaXsWahzRpvnIBdBjj7y+7EKTS6lnziU2y/PsU2c6rV5qYj2B5IDw0uNJ9peXD0vw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
lib0@0.2.114:
resolution: {integrity: sha512-gcxmNFzA4hv8UYi8j43uPlQ7CGcyMJ2KQb5kZASw6SnAKAf10hK12i2fjrS3Cl/ugZa5Ui6WwIu1/6MIXiHttQ==}
engines: {node: '>=16'}
hasBin: true
lie@3.3.0:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -6150,6 +6181,9 @@ packages:
resolution: {integrity: sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==}
engines: {node: ^14.18.0 || >=16.0.0}
tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
tailwindcss-primeui@0.6.1:
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
peerDependencies:
@@ -6876,6 +6910,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yjs@13.6.27:
resolution: {integrity: sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==}
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -8525,6 +8563,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@kurkle/color@0.3.4': {}
'@lobehub/cli-ui@1.13.0(@types/react@19.1.9)':
dependencies:
arr-rotate: 1.0.0
@@ -10436,6 +10476,10 @@ snapshots:
charenc@0.0.2: {}
chart.js@4.5.0:
dependencies:
'@kurkle/color': 0.3.4
check-error@2.1.1: {}
chokidar@3.6.0:
@@ -10485,6 +10529,8 @@ snapshots:
clone@1.0.4: {}
clsx@2.1.1: {}
code-excerpt@4.0.0:
dependencies:
convert-to-spaces: 2.0.1
@@ -11738,6 +11784,8 @@ snapshots:
isexe@2.0.0: {}
isomorphic.js@0.2.5: {}
istanbul-lib-coverage@3.2.2: {}
istanbul-lib-report@3.0.1:
@@ -11951,6 +11999,10 @@ snapshots:
lex@1.7.9: {}
lib0@0.2.114:
dependencies:
isomorphic.js: 0.2.5
lie@3.3.0:
dependencies:
immediate: 3.0.6
@@ -13734,6 +13786,8 @@ snapshots:
'@pkgr/core': 0.1.2
tslib: 2.8.1
tailwind-merge@3.3.1: {}
tailwindcss-primeui@0.6.1(tailwindcss@4.1.12):
dependencies:
tailwindcss: 4.1.12
@@ -14445,6 +14499,10 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yjs@13.6.27:
dependencies:
lib0: 0.2.114
yocto-queue@0.1.0: {}
yoctocolors@2.1.1: {}

View File

@@ -7,6 +7,66 @@
@config '../../../tailwind.config.ts';
@layer tailwind-utilities {
/* Set default values to prevent some styles from not working properly. */
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(66 153 225 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
}
@tailwind components;
@tailwind utilities;
}
:root {
--fg-color: #000;
--bg-color: #fff;
@@ -29,7 +89,7 @@
--content-fg: #000;
--content-hover-bg: #adadad;
--content-hover-fg: #000;
/* Code styling colors for help menu*/
--code-text-color: rgba(0, 122, 255, 1);
--code-bg-color: rgba(96, 165, 250, 0.2);
@@ -136,6 +196,188 @@ body {
border: thin solid;
}
/* Shared markdown content styling for consistent rendering across components */
.comfy-markdown-content {
/* Typography */
font-size: 0.875rem; /* text-sm */
line-height: 1.6;
word-wrap: break-word;
}
/* Headings */
.comfy-markdown-content h1 {
font-size: 22px; /* text-[22px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
}
.comfy-markdown-content h1:first-child {
margin-top: 0; /* first:mt-0 */
}
.comfy-markdown-content h2 {
font-size: 18px; /* text-[18px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
}
.comfy-markdown-content h2:first-child {
margin-top: 0; /* first:mt-0 */
}
.comfy-markdown-content h3 {
font-size: 16px; /* text-[16px] */
font-weight: 700; /* font-bold */
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
}
.comfy-markdown-content h3:first-child {
margin-top: 0; /* first:mt-0 */
}
.comfy-markdown-content h4,
.comfy-markdown-content h5,
.comfy-markdown-content h6 {
margin-top: 2rem; /* mt-8 */
margin-bottom: 1rem; /* mb-4 */
}
.comfy-markdown-content h4:first-child,
.comfy-markdown-content h5:first-child,
.comfy-markdown-content h6:first-child {
margin-top: 0; /* first:mt-0 */
}
/* Paragraphs */
.comfy-markdown-content p {
margin: 0 0 0.5em;
}
.comfy-markdown-content p:last-child {
margin-bottom: 0;
}
/* First child reset */
.comfy-markdown-content *:first-child {
margin-top: 0; /* mt-0 */
}
/* Lists */
.comfy-markdown-content ul,
.comfy-markdown-content ol {
padding-left: 2rem; /* pl-8 */
margin: 0.5rem 0; /* my-2 */
}
/* Nested lists */
.comfy-markdown-content ul ul,
.comfy-markdown-content ol ol,
.comfy-markdown-content ul ol,
.comfy-markdown-content ol ul {
padding-left: 1.5rem; /* pl-6 */
margin: 0.5rem 0; /* my-2 */
}
.comfy-markdown-content li {
margin: 0.5rem 0; /* my-2 */
}
/* Code */
.comfy-markdown-content code {
color: var(--code-text-color);
background-color: var(--code-bg-color);
border-radius: 0.25rem; /* rounded */
padding: 0.125rem 0.375rem; /* px-1.5 py-0.5 */
font-family: monospace;
}
.comfy-markdown-content pre {
background-color: var(--code-block-bg-color);
border-radius: 0.25rem; /* rounded */
padding: 1rem; /* p-4 */
margin: 1rem 0; /* my-4 */
overflow-x: auto; /* overflow-x-auto */
}
.comfy-markdown-content pre code {
background-color: transparent; /* bg-transparent */
padding: 0; /* p-0 */
color: var(--p-text-color);
}
/* Tables */
.comfy-markdown-content table {
width: 100%; /* w-full */
border-collapse: collapse; /* border-collapse */
}
.comfy-markdown-content th,
.comfy-markdown-content td {
padding: 0.5rem; /* px-2 py-2 */
}
.comfy-markdown-content th {
color: var(--fg-color);
}
.comfy-markdown-content td {
color: var(--drag-text);
}
.comfy-markdown-content tr {
border-bottom: 1px solid var(--content-bg);
}
.comfy-markdown-content tr:last-child {
border-bottom: none;
}
.comfy-markdown-content thead {
border-bottom: 1px solid var(--p-text-color);
}
/* Links */
.comfy-markdown-content a {
color: var(--drag-text);
text-decoration: underline;
}
/* Media */
.comfy-markdown-content img,
.comfy-markdown-content video {
max-width: 100%; /* max-w-full */
height: auto; /* h-auto */
display: block; /* block */
margin-bottom: 1rem; /* mb-4 */
}
/* Blockquotes */
.comfy-markdown-content blockquote {
border-left: 3px solid var(--p-primary-color, var(--primary-bg));
padding-left: 0.75em;
margin: 0.5em 0;
opacity: 0.8;
}
/* Horizontal rule */
.comfy-markdown-content hr {
border: none;
border-top: 1px solid var(--p-border-color, var(--border-color));
margin: 1em 0;
}
/* Strong and emphasis */
.comfy-markdown-content strong {
font-weight: bold;
}
.comfy-markdown-content em {
font-style: italic;
}
.comfy-modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
@@ -641,3 +883,92 @@ audio.comfy-audio.empty-audio-widget {
width: calc(100vw - env(titlebar-area-width, 100vw));
}
/* End of [Desktop] Electron window specific styles */
/* Vue Node LOD (Level of Detail) System */
/* These classes control rendering detail based on zoom level */
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
.lg-node--lod-minimal {
min-height: 32px;
transition: min-height 0.2s ease;
/* Performance optimizations */
text-shadow: none;
backdrop-filter: none;
}
.lg-node--lod-minimal .lg-node-body {
display: none !important;
}
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
.lg-node--lod-reduced {
transition: opacity 0.1s ease;
/* Performance optimizations */
text-shadow: none;
}
.lg-node--lod-reduced .lg-widget-label,
.lg-node--lod-reduced .lg-slot-label {
display: none;
}
.lg-node--lod-reduced .lg-slot {
opacity: 0.6;
font-size: 0.75rem;
}
.lg-node--lod-reduced .lg-widget {
margin: 2px 0;
font-size: 0.875rem;
}
/* Full LOD (zoom > 0.8) - Complete detail rendering */
.lg-node--lod-full {
/* Uses default styling - no overrides needed */
}
/* Smooth transitions between LOD levels */
.lg-node {
transition: min-height 0.2s ease;
/* Disable text selection on all nodes */
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.lg-node .lg-slot,
.lg-node .lg-widget {
transition: opacity 0.1s ease, font-size 0.1s ease;
}
/* Performance optimization during canvas interaction */
.transform-pane--interacting .lg-node * {
transition: none !important;
}
.transform-pane--interacting .lg-node {
will-change: transform;
}
/* Global performance optimizations for LOD */
.lg-node--lod-minimal,
.lg-node--lod-reduced {
/* Remove ALL expensive paint effects */
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
text-shadow: none !important;
-webkit-mask-image: none !important;
mask-image: none !important;
clip-path: none !important;
}
/* Reduce paint complexity for minimal LOD */
.lg-node--lod-minimal {
/* Skip complex borders */
border-radius: 0 !important;
/* Use solid colors only */
background-image: none !important;
}

View File

@@ -68,4 +68,73 @@ describe('EditableText', () => {
// @ts-expect-error fixme ts strict error
expect(wrapper.emitted('edit')[0]).toEqual(['Test Text'])
})
it('cancels editing on escape key', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Should emit cancel event
expect(wrapper.emitted('cancel')).toBeTruthy()
// Should NOT emit edit event
expect(wrapper.emitted('edit')).toBeFalsy()
// Input value should be reset to original
expect(wrapper.findComponent(InputText).props()['modelValue']).toBe(
'Original Text'
)
})
it('does not save changes when escape is pressed and blur occurs', async () => {
const wrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
// Change the input value
await wrapper.findComponent(InputText).setValue('Modified Text')
// Press escape (which triggers blur internally)
await wrapper.findComponent(InputText).trigger('keyup.escape')
// Manually trigger blur to simulate the blur that happens after escape
await wrapper.findComponent(InputText).trigger('blur')
// Should emit cancel but not edit
expect(wrapper.emitted('cancel')).toBeTruthy()
expect(wrapper.emitted('edit')).toBeFalsy()
})
it('saves changes on enter but not on escape', async () => {
// Test Enter key saves changes
const enterWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await enterWrapper.findComponent(InputText).setValue('Saved Text')
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
// Trigger blur that happens after enter
await enterWrapper.findComponent(InputText).trigger('blur')
expect(enterWrapper.emitted('edit')).toBeTruthy()
// @ts-expect-error fixme ts strict error
expect(enterWrapper.emitted('edit')[0]).toEqual(['Saved Text'])
// Test Escape key cancels changes with a fresh wrapper
const escapeWrapper = mountComponent({
modelValue: 'Original Text',
isEditing: true
})
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
expect(escapeWrapper.emitted('edit')).toBeFalsy()
})
})

View File

@@ -14,10 +14,12 @@
fluid
:pt="{
root: {
onBlur: finishEditing
onBlur: finishEditing,
...inputAttrs
}
}"
@keyup.enter="blurInputElement"
@keyup.escape="cancelEditing"
@click.stop
/>
</div>
@@ -27,21 +29,41 @@
import InputText from 'primevue/inputtext'
import { nextTick, ref, watch } from 'vue'
const { modelValue, isEditing = false } = defineProps<{
const {
modelValue,
isEditing = false,
inputAttrs = {}
} = defineProps<{
modelValue: string
isEditing?: boolean
inputAttrs?: Record<string, any>
}>()
const emit = defineEmits(['update:modelValue', 'edit'])
const emit = defineEmits(['update:modelValue', 'edit', 'cancel'])
const inputValue = ref<string>(modelValue)
const inputRef = ref<InstanceType<typeof InputText> | undefined>()
const isCanceling = ref(false)
const blurInputElement = () => {
// @ts-expect-error - $el is an internal property of the InputText component
inputRef.value?.$el.blur()
}
const finishEditing = () => {
emit('edit', inputValue.value)
// Don't save if we're canceling
if (!isCanceling.value) {
emit('edit', inputValue.value)
}
isCanceling.value = false
}
const cancelEditing = () => {
// Set canceling flag to prevent blur from saving
isCanceling.value = true
// Reset to original value
inputValue.value = modelValue
// Emit cancel event
emit('cancel')
// Blur the input to exit edit mode
blurInputElement()
}
watch(
() => isEditing,

View File

@@ -31,6 +31,35 @@
class="w-full h-full touch-none"
/>
<!-- TransformPane for Vue node rendering -->
<TransformPane
v-if="isVueNodesEnabled && canvasStore.canvas && comfyAppReady"
:canvas="canvasStore.canvas as LGraphCanvas"
@transform-update="handleTransformUpdate"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in nodesToRender"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:selected="nodeData.selected"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
@@ -39,13 +68,22 @@
<template v-if="comfyAppReady">
<TitleEditor />
<SelectionToolbox v-if="selectionToolboxEnabled" />
<DomWidgets />
<!-- Render legacy DOM widgets only when Vue nodes are disabled -->
<DomWidgets v-if="!shouldRenderVueNodes" />
</template>
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
import {
computed,
onMounted,
onUnmounted,
ref,
shallowRef,
watch,
watchEffect
} from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -55,10 +93,17 @@ import MiniMap from '@/components/graph/MiniMap.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import TransformPane from '@/components/graph/TransformPane.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
@@ -66,11 +111,19 @@ import { useCopy } from '@/composables/useCopy'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { LayoutSource } from '@/renderer/core/layout/types'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -102,6 +155,7 @@ const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const executionStore = useExecutionStore()
const toastStore = useToastStore()
const layoutMutations = useLayoutMutations()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -118,6 +172,281 @@ const selectionToolboxEnabled = computed(() =>
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
// Feature flags (Vue-related)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
// Vue node lifecycle management - initialize after graph is ready
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
let cleanupNodeManager: (() => void) | null = null
// Slot layout sync management
let slotSync: ReturnType<typeof useSlotLayoutSync> | null = null
let linkSync: ReturnType<typeof useLinkLayoutSync> | null = null
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
let detectChangesInRAF = () => {}
// Initialize node manager when graph becomes available
// Add a reactivity trigger to force computed re-evaluation
const nodeDataTrigger = ref(0)
const initializeNodeManager = () => {
if (!comfyApp.graph || nodeManager) return
nodeManager = useGraphNodeManager(comfyApp.graph)
cleanupNodeManager = nodeManager.cleanup
// Use the manager's reactive maps directly
vueNodeData.value = nodeManager.vueNodeData
nodeState.value = nodeManager.nodeState
nodePositions.value = nodeManager.nodePositions
nodeSizes.value = nodeManager.nodeSizes
detectChangesInRAF = nodeManager.detectChangesInRAF
// Initialize layout system with existing nodes
const nodes = comfyApp.graph._nodes.map((node: any) => ({
id: node.id.toString(),
pos: node.pos,
size: node.size
}))
layoutStore.initializeFromLiteGraph(nodes)
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of comfyApp.graph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of comfyApp.graph._links.values()) {
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
}
// Initialize layout sync (one-way: Layout Store → LiteGraph)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Initialize slot layout sync for hit detection
slotSync = useSlotLayoutSync()
if (canvasStore.canvas) {
slotSync.start(canvasStore.canvas as LGraphCanvas)
}
// Initialize link layout sync for event-driven updates
linkSync = useLinkLayoutSync()
if (canvasStore.canvas) {
linkSync.start(canvasStore.canvas as LGraphCanvas)
}
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
const disposeNodeManagerAndSyncs = () => {
if (!nodeManager) return
try {
cleanupNodeManager?.()
} catch {
/* empty */
}
nodeManager = null
cleanupNodeManager = null
// Clean up slot layout sync
if (slotSync) {
slotSync.stop()
slotSync = null
}
// Clean up link layout sync
if (linkSync) {
linkSync.stop()
linkSync = null
}
// Reset reactive maps to inert defaults
vueNodeData.value = new Map()
nodeState.value = new Map()
nodePositions.value = new Map()
nodeSizes.value = new Map()
}
// Watch for transformPaneEnabled to gate the node manager lifecycle
watch(
() => isVueNodesEnabled.value && Boolean(comfyApp.graph),
(enabled) => {
if (enabled) {
initializeNodeManager()
} else {
disposeNodeManagerAndSyncs()
}
},
{ immediate: true }
)
// Transform state for viewport culling
const { syncWithCanvas } = useTransformState()
const nodesToRender = computed(() => {
// Early return for zero overhead when Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return []
}
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
if (nodeManager && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
})
let lastScale = 1
let lastOffsetX = 0
let lastOffsetY = 0
const handleTransformUpdate = () => {
// Skip all work if Vue nodes are disabled
if (!isVueNodesEnabled.value) {
return
}
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
if (
currentScale !== lastScale ||
currentOffsetX !== lastOffsetX ||
currentOffsetY !== lastOffsetY
) {
syncWithCanvas(comfyApp.canvas)
lastScale = currentScale
lastOffsetX = currentOffsetX
lastOffsetY = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Trigger reactivity for nodesToRender
void nodesToRender.value.length
}
// Node event handlers
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager) return
const node = nodeManager.getNode(nodeData.id)
if (!node) return
if (!event.ctrlKey && !event.metaKey) {
canvasStore.canvas.deselectAllNodes()
}
canvasStore.canvas.selectNode(node)
// Bring node to front when clicked (similar to LiteGraph behavior)
// Skip if node is pinned
if (!node.flags?.pinned) {
layoutMutations.setSource(LayoutSource.Vue)
layoutMutations.bringNodeToFront(nodeData.id)
}
node.selected = true
canvasStore.updateSelectedItems()
}
// Handle node collapse state changes
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) {
node.collapse()
}
}
// Handle node title updates
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Update the node title in LiteGraph for persistence
node.title = newTitle
}
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -286,6 +615,7 @@ onMounted(async () => {
useCopy()
usePaste()
useWorkflowAutoSave()
useVueFeatureFlags()
comfyApp.vueAppReady = true
@@ -298,9 +628,6 @@ onMounted(async () => {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log(
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
)
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
@@ -326,6 +653,30 @@ onMounted(async () => {
comfyAppReady.value = true
// Set up Vue node initialization only when enabled
if (isVueNodesEnabled.value) {
// Set up a one-time listener for when the first node is added
// This handles the case where Vue nodes are enabled but the graph starts empty
// TODO: Replace this with a reactive graph mutations observer when available
if (comfyApp.graph && !nodeManager && comfyApp.graph._nodes.length === 0) {
const originalOnNodeAdded = comfyApp.graph.onNodeAdded
comfyApp.graph.onNodeAdded = function (node: any) {
// Restore original handler
comfyApp.graph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed
if (isVueNodesEnabled.value && !nodeManager) {
initializeNodeManager()
}
// Call original handler
if (originalOnNodeAdded) {
originalOnNodeAdded.call(this, node)
}
}
}
}
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
@@ -366,4 +717,19 @@ onMounted(async () => {
emit('ready')
})
onUnmounted(() => {
if (nodeManager) {
nodeManager.cleanup()
nodeManager = null
}
if (slotSync) {
slotSync.stop()
slotSync = null
}
if (linkSync) {
linkSync.stop()
linkSync = null
}
})
</script>

View File

@@ -0,0 +1,350 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import TransformPane from './TransformPane.vue'
// Mock the transform state composable
const mockTransformState = {
camera: ref({ x: 0, y: 0, z: 1 }),
transformStyle: ref({
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
}),
syncWithCanvas: vi.fn(),
canvasToScreen: vi.fn(),
screenToCanvas: vi.fn(),
isNodeInViewport: vi.fn()
}
vi.mock('@/composables/element/useTransformState', () => ({
useTransformState: () => mockTransformState
}))
// Mock requestAnimationFrame/cancelAnimationFrame
global.requestAnimationFrame = vi.fn((cb) => {
setTimeout(cb, 16)
return 1
})
global.cancelAnimationFrame = vi.fn()
describe('TransformPane', () => {
let wrapper: ReturnType<typeof mount>
let mockCanvas: any
beforeEach(() => {
vi.clearAllMocks()
// Create mock canvas with LiteGraph interface
mockCanvas = {
canvas: {
addEventListener: vi.fn(),
removeEventListener: vi.fn()
},
ds: {
offset: [0, 0],
scale: 1
}
}
// Reset mock transform state
mockTransformState.camera.value = { x: 0, y: 0, z: 1 }
mockTransformState.transformStyle.value = {
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
}
})
afterEach(() => {
if (wrapper) {
wrapper.unmount()
}
})
describe('component mounting', () => {
it('should mount successfully with minimal props', () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.transform-pane').exists()).toBe(true)
})
it('should apply transform style from composable', () => {
mockTransformState.transformStyle.value = {
transform: 'scale(2) translate(100px, 50px)',
transformOrigin: '0 0'
}
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
const transformPane = wrapper.find('.transform-pane')
const style = transformPane.attributes('style')
expect(style).toContain('transform: scale(2) translate(100px, 50px)')
})
it('should render slot content', () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
},
slots: {
default: '<div class="test-content">Test Node</div>'
}
})
expect(wrapper.find('.test-content').exists()).toBe(true)
expect(wrapper.find('.test-content').text()).toBe('Test Node')
})
})
describe('RAF synchronization', () => {
it('should start RAF sync on mount', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
// Should emit RAF status change to true
expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
expect(wrapper.emitted('rafStatusChange')?.[0]).toEqual([true])
})
it('should call syncWithCanvas during RAF updates', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
// Allow RAF to execute
await new Promise((resolve) => setTimeout(resolve, 20))
expect(mockTransformState.syncWithCanvas).toHaveBeenCalledWith(mockCanvas)
})
it('should emit transform update timing', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
// Allow RAF to execute
await new Promise((resolve) => setTimeout(resolve, 20))
expect(wrapper.emitted('transformUpdate')).toBeTruthy()
const updateEvent = wrapper.emitted('transformUpdate')?.[0]
expect(typeof updateEvent?.[0]).toBe('number')
expect(updateEvent?.[0]).toBeGreaterThanOrEqual(0)
})
it('should stop RAF sync on unmount', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
wrapper.unmount()
expect(wrapper.emitted('rafStatusChange')).toBeTruthy()
const events = wrapper.emitted('rafStatusChange') as any[]
expect(events[events.length - 1]).toEqual([false])
expect(global.cancelAnimationFrame).toHaveBeenCalled()
})
})
describe('canvas event listeners', () => {
it('should add event listeners to canvas on mount', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.addEventListener).toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.any(Object)
)
})
it('should remove event listeners on unmount', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
await nextTick()
wrapper.unmount()
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
'wheel',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
expect.any(Object)
)
expect(mockCanvas.canvas.removeEventListener).toHaveBeenCalledWith(
'pointercancel',
expect.any(Function),
expect.any(Object)
)
})
})
describe('interaction state management', () => {
it('should apply interacting class during interactions', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
// Simulate interaction start by checking internal state
// Note: This tests the CSS class application logic
const transformPane = wrapper.find('.transform-pane')
// Initially should not have interacting class
expect(transformPane.classes()).not.toContain(
'transform-pane--interacting'
)
})
it('should handle pointer events for node delegation', async () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
const transformPane = wrapper.find('.transform-pane')
// Simulate pointer down - we can't test the exact delegation logic
// in unit tests due to vue-test-utils limitations, but we can verify
// the event handler is set up correctly
await transformPane.trigger('pointerdown')
// The test passes if no errors are thrown during event handling
expect(transformPane.exists()).toBe(true)
})
})
describe('transform state integration', () => {
it('should provide transform utilities to child components', () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
// The component should provide transform state via Vue's provide/inject
// This is tested indirectly through the composable integration
expect(mockTransformState.syncWithCanvas).toBeDefined()
expect(mockTransformState.canvasToScreen).toBeDefined()
expect(mockTransformState.screenToCanvas).toBeDefined()
})
})
describe('error handling', () => {
it('should handle null canvas gracefully', () => {
wrapper = mount(TransformPane, {
props: {
canvas: undefined
}
})
expect(wrapper.exists()).toBe(true)
expect(wrapper.find('.transform-pane').exists()).toBe(true)
})
it('should handle missing canvas properties', () => {
const incompleteCanvas = {} as any
wrapper = mount(TransformPane, {
props: {
canvas: incompleteCanvas
}
})
expect(wrapper.exists()).toBe(true)
// Should not throw errors during mount
})
})
describe('performance optimizations', () => {
it('should use contain CSS property for layout optimization', () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
}
})
const transformPane = wrapper.find('.transform-pane')
// This test verifies the CSS contains the performance optimization
// Note: In JSDOM, computed styles might not reflect all CSS properties
expect(transformPane.element.className).toContain('transform-pane')
})
it('should disable pointer events on container but allow on children', () => {
wrapper = mount(TransformPane, {
props: {
canvas: mockCanvas
},
slots: {
default: '<div data-node-id="test">Test Node</div>'
}
})
const transformPane = wrapper.find('.transform-pane')
// The CSS should handle pointer events optimization
// This is primarily a CSS concern, but we verify the structure
expect(transformPane.exists()).toBe(true)
expect(wrapper.find('[data-node-id="test"]').exists()).toBe(true)
})
})
})

View File

@@ -0,0 +1,91 @@
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
<!-- Vue nodes will be rendered here -->
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface TransformPaneProps {
canvas?: LGraphCanvas
}
const props = defineProps<TransformPaneProps>()
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 200,
trackPan: true
})
provide('transformState', {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement
const nodeElement = target.closest('[data-node-id]')
if (nodeElement) {
// TODO: Emit event for node interaction
// Node interaction with nodeId will be handled in future implementation
}
}
const emit = defineEmits<{
rafStatusChange: [active: boolean]
transformUpdate: [time: number]
}>()
useCanvasTransformSync(props.canvas, syncWithCanvas, {
onStart: () => emit('rafStatusChange', true),
onUpdate: (duration) => emit('transformUpdate', duration),
onStop: () => emit('rafStatusChange', false)
})
</script>
<style scoped>
.transform-pane {
position: absolute;
inset: 0;
transform-origin: 0 0;
pointer-events: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.transform-pane--interacting {
will-change: transform;
}
/* Allow pointer events on nodes */
.transform-pane :deep([data-node-id]) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,242 @@
/**
* Composable for managing transform state synchronized with LiteGraph canvas
*
* This composable is a critical part of the hybrid rendering architecture that
* allows Vue components to render in perfect alignment with LiteGraph's canvas.
*
* ## Core Concept
*
* LiteGraph uses a canvas for rendering connections, grid, and handling interactions.
* Vue components need to render nodes on top of this canvas. The challenge is
* synchronizing the coordinate systems:
*
* - LiteGraph: Uses canvas coordinates with its own transform matrix
* - Vue/DOM: Uses screen coordinates with CSS transforms
*
* ## Solution: Transform Container Pattern
*
* Instead of transforming individual nodes (O(n) complexity), we:
* 1. Mirror LiteGraph's transform matrix to a single CSS container
* 2. Place all Vue nodes as children with simple absolute positioning
* 3. Achieve O(1) transform updates regardless of node count
*
* ## Coordinate Systems
*
* - **Canvas coordinates**: LiteGraph's internal coordinate system
* - **Screen coordinates**: Browser's viewport coordinate system
* - **Transform sync**: camera.x/y/z mirrors canvas.ds.offset/scale
*
* ## Performance Benefits
*
* - GPU acceleration via CSS transforms
* - No layout thrashing (only transform changes)
* - Efficient viewport culling calculations
* - Scales to 1000+ nodes while maintaining 60 FPS
*
* @example
* ```typescript
* const { camera, transformStyle, canvasToScreen } = useTransformState()
*
* // In template
* <div :style="transformStyle">
* <NodeComponent
* v-for="node in nodes"
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
* />
* </div>
*
* // Convert coordinates
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })
* ```
*/
import { computed, reactive, readonly } from 'vue'
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
export interface Point {
x: number
y: number
}
export interface Camera {
x: number
y: number
z: number // scale/zoom
}
export const useTransformState = () => {
// Reactive state mirroring LiteGraph's canvas transform
const camera = reactive<Camera>({
x: 0,
y: 0,
z: 1
})
// Computed transform string for CSS
const transformStyle = computed(() => ({
transform: `scale(${camera.z}) translate(${camera.x}px, ${camera.y}px)`,
transformOrigin: '0 0'
}))
/**
* Synchronizes Vue's reactive camera state with LiteGraph's canvas transform
*
* Called every frame via RAF to ensure Vue components stay aligned with canvas.
* This is the heart of the hybrid rendering system - it bridges the gap between
* LiteGraph's canvas transforms and Vue's reactive system.
*
* @param canvas - LiteGraph canvas instance with DragAndScale (ds) transform state
*/
const syncWithCanvas = (canvas: LGraphCanvas) => {
if (!canvas || !canvas.ds) return
// Mirror LiteGraph's transform state to Vue's reactive state
// ds.offset = pan offset, ds.scale = zoom level
camera.x = canvas.ds.offset[0]
camera.y = canvas.ds.offset[1]
camera.z = canvas.ds.scale || 1
}
/**
* Converts canvas coordinates to screen coordinates
*
* Applies the same transform that LiteGraph uses for rendering.
* Essential for positioning Vue components to align with canvas elements.
*
* Formula: screen = canvas * scale + offset
*
* @param point - Point in canvas coordinate system
* @returns Point in screen coordinate system
*/
const canvasToScreen = (point: Point): Point => {
return {
x: point.x * camera.z + camera.x,
y: point.y * camera.z + camera.y
}
}
/**
* Converts screen coordinates to canvas coordinates
*
* Inverse of canvasToScreen. Useful for hit testing and converting
* mouse events back to canvas space.
*
* Formula: canvas = (screen - offset) / scale
*
* @param point - Point in screen coordinate system
* @returns Point in canvas coordinate system
*/
const screenToCanvas = (point: Point): Point => {
return {
x: (point.x - camera.x) / camera.z,
y: (point.y - camera.y) / camera.z
}
}
// Get node's screen bounds for culling
const getNodeScreenBounds = (
pos: ArrayLike<number>,
size: ArrayLike<number>
): DOMRect => {
const topLeft = canvasToScreen({ x: pos[0], y: pos[1] })
const width = size[0] * camera.z
const height = size[1] * camera.z
return new DOMRect(topLeft.x, topLeft.y, width, height)
}
// Helper: Calculate zoom-adjusted margin for viewport culling
const calculateAdjustedMargin = (baseMargin: number): number => {
if (camera.z < 0.1) return Math.min(baseMargin * 5, 2.0)
if (camera.z > 3.0) return Math.max(baseMargin * 0.5, 0.05)
return baseMargin
}
// Helper: Check if node is too small to be visible at current zoom
const isNodeTooSmall = (nodeSize: ArrayLike<number>): boolean => {
const nodeScreenSize = Math.max(nodeSize[0], nodeSize[1]) * camera.z
return nodeScreenSize < 4
}
// Helper: Calculate expanded viewport bounds with margin
const getExpandedViewportBounds = (
viewport: { width: number; height: number },
margin: number
) => {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
return {
left: -marginX,
right: viewport.width + marginX,
top: -marginY,
bottom: viewport.height + marginY
}
}
// Helper: Test if node intersects with viewport bounds
const testViewportIntersection = (
screenPos: { x: number; y: number },
nodeSize: ArrayLike<number>,
bounds: { left: number; right: number; top: number; bottom: number }
): boolean => {
const nodeRight = screenPos.x + nodeSize[0] * camera.z
const nodeBottom = screenPos.y + nodeSize[1] * camera.z
return !(
nodeRight < bounds.left ||
screenPos.x > bounds.right ||
nodeBottom < bounds.top ||
screenPos.y > bounds.bottom
)
}
// Check if node is within viewport with frustum and size-based culling
const isNodeInViewport = (
nodePos: ArrayLike<number>,
nodeSize: ArrayLike<number>,
viewport: { width: number; height: number },
margin: number = 0.2
): boolean => {
// Early exit for tiny nodes
if (isNodeTooSmall(nodeSize)) return false
const screenPos = canvasToScreen({ x: nodePos[0], y: nodePos[1] })
const adjustedMargin = calculateAdjustedMargin(margin)
const bounds = getExpandedViewportBounds(viewport, adjustedMargin)
return testViewportIntersection(screenPos, nodeSize, bounds)
}
// Get viewport bounds in canvas coordinates (for spatial index queries)
const getViewportBounds = (
viewport: { width: number; height: number },
margin: number = 0.2
) => {
const marginX = viewport.width * margin
const marginY = viewport.height * margin
const topLeft = screenToCanvas({ x: -marginX, y: -marginY })
const bottomRight = screenToCanvas({
x: viewport.width + marginX,
y: viewport.height + marginY
})
return {
x: topLeft.x,
y: topLeft.y,
width: bottomRight.x - topLeft.x,
height: bottomRight.y - topLeft.y
}
}
return {
camera: readonly(camera),
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
getNodeScreenBounds,
isNodeInViewport,
getViewportBounds
}
}

View File

@@ -0,0 +1,115 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '../../lib/litegraph/src/litegraph'
export interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
}
export interface CanvasTransformSyncCallbacks {
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called after each sync update with timing information
*/
onUpdate?: (duration: number) => void
/**
* Called when sync stops
*/
onStop?: () => void
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, provides performance timing,
* and ensures proper cleanup.
*
* The sync function typically reads canvas.ds (draw state) properties like offset and scale
* to keep Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* canvas,
* (canvas) => syncWithCanvas(canvas),
* {
* onStart: () => emit('rafStatusChange', true),
* onUpdate: (time) => emit('transformUpdate', time),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
canvas: LGraphCanvas | undefined | null,
syncFn: (canvas: LGraphCanvas) => void,
callbacks: CanvasTransformSyncCallbacks = {},
options: CanvasTransformSyncOptions = {}
) {
const { autoStart = true } = options
const { onStart, onUpdate, onStop } = callbacks
const isActive = ref(false)
let rafId: number | null = null
const startSync = () => {
if (isActive.value || !canvas) return
isActive.value = true
onStart?.()
const sync = () => {
if (!isActive.value || !canvas) return
try {
const startTime = performance.now()
syncFn(canvas)
const endTime = performance.now()
onUpdate?.(endTime - startTime)
} catch (error) {
console.warn('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
sync()
}
const stopSync = () => {
if (!isActive.value) return
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
isActive.value = false
onStop?.()
}
// Auto-start if canvas is available and autoStart is enabled
if (autoStart && canvas) {
startSync()
}
// Clean up on unmount
onUnmounted(() => {
stopSync()
})
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -0,0 +1,813 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { nextTick, reactive, readonly } from 'vue'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
import { type Bounds, QuadTree } from '../../utils/spatial/QuadTree'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
export interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
spatialIndex?: QuadTree<string>
}
export interface PerformanceMetrics {
fps: number
frameTime: number
updateTime: number
nodeCount: number
culledCount: number
callbackUpdateCount: number
rafUpdateCount: number
adaptiveQuality: boolean
}
export interface SafeWidgetData {
name: string
type: string
value: WidgetValue
options?: Record<string, unknown>
callback?: ((value: unknown) => void) | undefined
}
export interface VueNodeData {
id: string
title: string
type: string
mode: number
selected: boolean
executing: boolean
widgets?: SafeWidgetData[]
inputs?: unknown[]
outputs?: unknown[]
flags?: {
collapsed?: boolean
}
}
export interface SpatialMetrics {
queryTime: number
nodesInIndex: number
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState>
nodePositions: ReadonlyMap<string, { x: number; y: number }>
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined
// Lifecycle methods
setupEventListeners(): () => void
cleanup(): void
// Update methods
scheduleUpdate(
nodeId?: string,
priority?: 'critical' | 'normal' | 'low'
): void
forceSync(): void
detectChangesInRAF(): void
// Spatial queries
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
// Debug
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
}
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Get layout mutations composable
const { moveNode, resizeNode, createNode, deleteNode, setSource } =
useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy data that auto-GCs when nodes are removed
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Performance tracking
const performanceMetrics = reactive<PerformanceMetrics>({
fps: 0,
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
callbackUpdateCount: 0,
rafUpdateCount: 0,
adaptiveQuality: false
})
// Spatial indexing using QuadTree
const spatialIndex = new QuadTree<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
queryTime: 0,
nodesInIndex: 0
})
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
// Change detection state
const lastNodesSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high',
spatialIndex: undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Extract safe widget data
const safeWidgets = node.widgets?.map((widget) => {
try {
// TODO: Use widget.getReactiveData() once TypeScript types are updated
let value = widget.value
// For combo widgets, if value is undefined, use the first option as default
if (
value === undefined &&
widget.type === 'combo' &&
widget.options?.values &&
Array.isArray(widget.options.values) &&
widget.options.values.length > 0
) {
value = widget.options.values[0]
}
return {
name: widget.name,
type: widget.type,
value: value,
options: widget.options ? { ...widget.options } : undefined,
callback: widget.callback
}
} catch (error) {
return {
name: widget.name || 'unknown',
type: widget.type || 'text',
value: undefined, // Already a valid WidgetValue
options: undefined,
callback: undefined
}
}
})
return {
id: String(node.id),
title: node.title || 'Untitled',
type: node.type || 'Unknown',
mode: node.mode || 0,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
}
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: string): LGraphNode | undefined => {
return nodeRefs.get(id)
}
/**
* Validates that a value is a valid WidgetValue type
*/
const validateWidgetValue = (value: unknown): WidgetValue => {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (Array.isArray(value) && value.every((item) => item instanceof File)) {
return value as File[]
}
// Otherwise it's a generic object
return value as object
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
/**
* Updates Vue state when widget values change
*/
const updateVueWidgetState = (
nodeId: string,
widgetName: string,
value: unknown
): void => {
try {
const currentData = vueNodeData.get(nodeId)
if (!currentData?.widgets) return
const updatedWidgets = currentData.widgets.map((w) =>
w.name === widgetName ? { ...w, value: validateWidgetValue(value) } : w
)
vueNodeData.set(nodeId, {
...currentData,
widgets: updatedWidgets
})
performanceMetrics.callbackUpdateCount++
} catch (error) {
// Ignore widget update errors to prevent cascade failures
}
}
/**
* Creates a wrapped callback for a widget that maintains LiteGraph/Vue sync
*/
const createWrappedWidgetCallback = (
widget: { value?: unknown; name: string }, // LiteGraph widget with minimal typing
originalCallback: ((value: unknown) => void) | undefined,
nodeId: string
) => {
let updateInProgress = false
return (value: unknown) => {
if (updateInProgress) return
updateInProgress = true
try {
// 1. Update the widget value in LiteGraph (critical for LiteGraph state)
// Validate that the value is of an acceptable type
if (
value !== null &&
value !== undefined &&
typeof value !== 'string' &&
typeof value !== 'number' &&
typeof value !== 'boolean' &&
typeof value !== 'object'
) {
console.warn(`Invalid widget value type: ${typeof value}`)
updateInProgress = false
return
}
// Always update widget.value to ensure sync
widget.value = value
// 2. Call the original callback if it exists
if (originalCallback) {
originalCallback.call(widget, value)
}
// 3. Update Vue state to maintain synchronization
updateVueWidgetState(nodeId, widget.name, value)
} finally {
updateInProgress = false
}
}
}
/**
* Sets up widget callbacks for a node - now with reduced nesting
*/
const setupNodeWidgetCallbacks = (node: LGraphNode) => {
if (!node.widgets) return
const nodeId = String(node.id)
node.widgets.forEach((widget) => {
const originalCallback = widget.callback
widget.callback = createWrappedWidgetCallback(
widget,
originalCallback,
nodeId
)
})
}
// Uncomment when needed for future features
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
// let metadata = nodeMetadata.get(node)
// if (!metadata) {
// attachMetadata(node)
// metadata = nodeMetadata.get(node)!
// }
// return metadata
// }
const scheduleUpdate = (
nodeId?: string,
priority: 'critical' | 'normal' | 'low' = 'normal'
) => {
if (nodeId) {
const state = nodeState.get(nodeId)
if (state) state.dirty = true
// Priority queuing
if (priority === 'critical') {
criticalUpdates.add(nodeId)
flush() // Immediate flush for critical updates
return
} else if (priority === 'low') {
lowPriorityUpdates.add(nodeId)
} else {
pendingUpdates.add(nodeId)
}
}
if (!updateScheduled) {
updateScheduled = true
// Adaptive batching strategy
if (pendingUpdates.size > 10) {
// Many updates - batch in nextTick
void nextTick(() => flush())
} else {
// Few updates - small delay for more batching
batchTimeoutId = window.setTimeout(() => flush(), 4)
}
}
}
const flush = () => {
const startTime = performance.now()
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all pending updates
criticalUpdates.clear()
pendingUpdates.clear()
lowPriorityUpdates.clear()
updateScheduled = false
// Sync with graph state
syncWithGraph()
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
}
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => String(n.id)))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
spatialIndex.remove(id)
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = String(node.id)
// Store non-reactive reference
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
if (!nodeState.has(id)) {
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
}
})
// Update performance metrics
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(s) => s.culled
).length
}
// Most performant: Direct position sync without re-setting entire node
// Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
const startTime = performance.now()
// Use QuadTree for fast spatial query
const results: string[] = spatialIndex.query(viewportBounds)
const visibleIds = new Set(results)
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
return visibleIds
}
/**
* Detects position changes for a single node and updates reactive state
*/
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node and updates reactive state
*/
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Updates spatial index for a node if bounds changed
*/
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.update(id, bounds)
}
/**
* Updates performance metrics after change detection
*/
const updatePerformanceMetrics = (
startTime: number,
positionUpdates: number,
sizeUpdates: number
): void => {
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(state) => state.culled
).length
spatialMetrics.nodesInIndex = spatialIndex.size
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
}
}
/**
* Main RAF change detection function
*/
const detectChangesInRAF = () => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
setSource(LayoutSource.Canvas)
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, id)
const sizeChanged = detectSizeChanges(node, id)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Update spatial index if geometry changed
if (posChanged || sizeChanged) {
updateSpatialIndex(node, id)
}
}
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Set up widget callbacks BEFORE extracting data (critical order)
setupNodeWidgetCallbacks(node)
// Extract safe data for Vue (now with proper callbacks)
vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index for viewport culling
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
// Add node to layout store
setSource(LayoutSource.Canvas)
void createNode(id, {
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
}
/**
* Handles node removal from the graph - cleans up all references
*/
const handleNodeRemoved = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = String(node.id)
// Remove from spatial index
spatialIndex.remove(id)
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
// Clean up all tracking references
nodeRefs.delete(id)
vueNodeData.delete(id)
nodeState.delete(id)
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
// Call original callback if provided
if (originalCallback) {
originalCallback(node)
}
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
originalOnTrigger: ((action: string, param: unknown) => void) | undefined
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
// Clear pending updates
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
nodeState.clear()
nodePositions.clear()
nodeSizes.clear()
lastNodesSnapshot.clear()
pendingUpdates.clear()
criticalUpdates.clear()
lowPriorityUpdates.clear()
spatialIndex.clear()
}
}
/**
* Sets up event listeners - now simplified with extracted handlers
*/
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
graph.onNodeRemoved = (node: LGraphNode) => {
handleNodeRemoved(node, originalOnNodeRemoved)
}
// Listen for property change events from instrumented nodes
graph.onTrigger = (action: string, param: unknown) => {
if (
action === 'node:property:changed' &&
param &&
typeof param === 'object'
) {
const event = param as {
nodeId: string | number
property: string
oldValue: unknown
newValue: unknown
}
const nodeId = String(event.nodeId)
const currentData = vueNodeData.get(nodeId)
if (currentData) {
if (event.property === 'title') {
vueNodeData.set(nodeId, {
...currentData,
title: String(event.newValue)
})
} else if (event.property === 'flags.collapsed') {
vueNodeData.set(nodeId, {
...currentData,
flags: {
...currentData.flags,
collapsed: Boolean(event.newValue)
}
})
}
}
}
// Call original trigger handler if it exists
if (originalOnTrigger) {
originalOnTrigger(action, param)
}
}
// Initialize state
syncWithGraph()
// Return cleanup function
return createCleanupFunction(
originalOnNodeAdded || undefined,
originalOnNodeRemoved || undefined,
originalOnTrigger || undefined
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {
graph.onNodeAdded(node)
}
})
}
return {
vueNodeData: readonly(vueNodeData) as ReadonlyMap<string, VueNodeData>,
nodeState: readonly(nodeState) as ReadonlyMap<string, NodeState>,
nodePositions: readonly(nodePositions) as ReadonlyMap<
string,
{ x: number; y: number }
>,
nodeSizes: readonly(nodeSizes) as ReadonlyMap<
string,
{ width: number; height: number }
>,
getNode,
setupEventListeners,
cleanup,
scheduleUpdate,
forceSync: syncWithGraph,
detectChangesInRAF,
getVisibleNodeIds,
performanceMetrics,
spatialMetrics: readonly(spatialMetrics),
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
}
}

View File

@@ -0,0 +1,198 @@
/**
* Composable for spatial indexing of nodes using QuadTree
* Integrates with useGraphNodeManager for efficient viewport culling
*/
import { useDebounceFn } from '@vueuse/core'
import { computed, reactive, ref } from 'vue'
import { type Bounds, QuadTree } from '@/utils/spatial/QuadTree'
export interface SpatialIndexOptions {
worldBounds?: Bounds
maxDepth?: number
maxItemsPerNode?: number
updateDebounceMs?: number
}
interface SpatialMetrics {
queryTime: number
totalNodes: number
visibleNodes: number
treeDepth: number
rebuildCount: number
}
export const useSpatialIndex = (options: SpatialIndexOptions = {}) => {
// Default world bounds (can be expanded dynamically)
const defaultBounds: Bounds = {
x: -10000,
y: -10000,
width: 20000,
height: 20000
}
// QuadTree instance
const quadTree = ref<QuadTree<string> | null>(null)
// Performance metrics
const metrics = reactive<SpatialMetrics>({
queryTime: 0,
totalNodes: 0,
visibleNodes: 0,
treeDepth: 0,
rebuildCount: 0
})
// Initialize QuadTree
const initialize = (bounds: Bounds = defaultBounds) => {
quadTree.value = new QuadTree<string>(bounds, {
maxDepth: options.maxDepth ?? 6,
maxItemsPerNode: options.maxItemsPerNode ?? 4
})
metrics.rebuildCount++
}
// Add or update node in spatial index
const updateNode = (
nodeId: string,
position: { x: number; y: number },
size: { width: number; height: number }
) => {
if (!quadTree.value) {
initialize()
}
const bounds: Bounds = {
x: position.x,
y: position.y,
width: size.width,
height: size.height
}
// Use insert instead of update - insert handles both new and existing nodes
quadTree.value!.insert(nodeId, bounds, nodeId)
metrics.totalNodes = quadTree.value!.size
}
// Batch update for multiple nodes
const batchUpdate = (
updates: Array<{
id: string
position: { x: number; y: number }
size: { width: number; height: number }
}>
) => {
if (!quadTree.value) {
initialize()
}
for (const update of updates) {
const bounds: Bounds = {
x: update.position.x,
y: update.position.y,
width: update.size.width,
height: update.size.height
}
// Use insert instead of update - insert handles both new and existing nodes
quadTree.value!.insert(update.id, bounds, update.id)
}
metrics.totalNodes = quadTree.value!.size
}
// Remove node from spatial index
const removeNode = (nodeId: string) => {
if (!quadTree.value) return
quadTree.value.remove(nodeId)
metrics.totalNodes = quadTree.value.size
}
// Query nodes within viewport bounds
const queryViewport = (viewportBounds: Bounds): string[] => {
if (!quadTree.value) return []
const startTime = performance.now()
const nodeIds = quadTree.value.query(viewportBounds)
const queryTime = performance.now() - startTime
metrics.queryTime = queryTime
metrics.visibleNodes = nodeIds.length
return nodeIds
}
// Get nodes within a radius (for proximity queries)
const queryRadius = (
center: { x: number; y: number },
radius: number
): string[] => {
if (!quadTree.value) return []
const bounds: Bounds = {
x: center.x - radius,
y: center.y - radius,
width: radius * 2,
height: radius * 2
}
return quadTree.value.query(bounds)
}
// Clear all nodes
const clear = () => {
if (!quadTree.value) return
quadTree.value.clear()
metrics.totalNodes = 0
metrics.visibleNodes = 0
}
// Rebuild tree (useful after major layout changes)
const rebuild = (
nodes: Map<
string,
{
position: { x: number; y: number }
size: { width: number; height: number }
}
>
) => {
initialize()
const updates = Array.from(nodes.entries()).map(([id, data]) => ({
id,
position: data.position,
size: data.size
}))
batchUpdate(updates)
}
// Debounced update for performance
const debouncedUpdateNode = useDebounceFn(
updateNode,
options.updateDebounceMs ?? 16
)
return {
// Core functions
initialize,
updateNode,
batchUpdate,
removeNode,
queryViewport,
queryRadius,
clear,
rebuild,
// Debounced version for high-frequency updates
debouncedUpdateNode,
// Metrics
metrics: computed(() => metrics),
// Direct access to QuadTree (for advanced usage)
quadTree: computed(() => quadTree.value)
}
}

View File

@@ -0,0 +1,151 @@
import { useDebounceFn, useEventListener, useThrottleFn } from '@vueuse/core'
import { ref } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
export interface TransformSettlingOptions {
/**
* Delay in ms before transform is considered "settled" after last interaction
* @default 200
*/
settleDelay?: number
/**
* Whether to track both zoom (wheel) and pan (pointer drag) interactions
* @default false
*/
trackPan?: boolean
/**
* Throttle delay for high-frequency pointermove events (only used when trackPan is true)
* @default 16 (~60fps)
*/
pointerMoveThrottle?: number
/**
* Whether to use passive event listeners (better performance but can't preventDefault)
* @default true
*/
passive?: boolean
}
/**
* Tracks when canvas transforms (zoom/pan) are actively changing vs settled.
*
* This composable helps optimize rendering quality during transformations.
* When the user is actively zooming or panning, we can reduce rendering quality
* for better performance. Once the transform "settles" (stops changing), we can
* trigger high-quality re-rasterization.
*
* The settling concept prevents constant quality switching during interactions
* by waiting for a period of inactivity before considering the transform complete.
*
* Uses VueUse's useEventListener for automatic cleanup and useDebounceFn for
* efficient settle detection.
*
* @example
* ```ts
* const { isTransforming } = useTransformSettling(canvasRef, {
* settleDelay: 200,
* trackPan: true
* })
*
* // Use in CSS classes or rendering logic
* const cssClass = computed(() => ({
* 'low-quality': isTransforming.value,
* 'high-quality': !isTransforming.value
* }))
* ```
*/
export function useTransformSettling(
target: MaybeRefOrGetter<HTMLElement | null | undefined>,
options: TransformSettlingOptions = {}
) {
const {
settleDelay = 200,
trackPan = false,
pointerMoveThrottle = 16,
passive = true
} = options
const isTransforming = ref(false)
let isPanning = false
/**
* Mark transform as active
*/
const markTransformActive = () => {
isTransforming.value = true
}
/**
* Mark transform as settled (debounced)
*/
const markTransformSettled = useDebounceFn(() => {
isTransforming.value = false
}, settleDelay)
/**
* Handle any transform event - mark active then queue settle
*/
const handleTransformEvent = () => {
markTransformActive()
void markTransformSettled()
}
// Wheel handler
const handleWheel = () => {
handleTransformEvent()
}
// Pointer handlers for panning
const handlePointerDown = () => {
if (trackPan) {
isPanning = true
handleTransformEvent()
}
}
// Throttled pointer move handler for performance
const handlePointerMove = trackPan
? useThrottleFn(() => {
if (isPanning) {
handleTransformEvent()
}
}, pointerMoveThrottle)
: undefined
const handlePointerEnd = () => {
if (trackPan) {
isPanning = false
// Don't immediately stop - let the debounced settle handle it
}
}
// Register event listeners with auto-cleanup
useEventListener(target, 'wheel', handleWheel, {
capture: true,
passive
})
if (trackPan) {
useEventListener(target, 'pointerdown', handlePointerDown, {
capture: true
})
if (handlePointerMove) {
useEventListener(target, 'pointermove', handlePointerMove, {
capture: true,
passive
})
}
useEventListener(target, 'pointerup', handlePointerEnd, {
capture: true
})
useEventListener(target, 'pointercancel', handlePointerEnd, {
capture: true
})
}
return {
isTransforming
}
}

View File

@@ -0,0 +1,155 @@
/**
* Composable for managing widget value synchronization between Vue and LiteGraph
* Provides consistent pattern for immediate UI updates and LiteGraph callbacks
*/
import { type Ref, ref, watch } from 'vue'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
export interface UseWidgetValueOptions<
T extends WidgetValue = WidgetValue,
U = T
> {
/** The widget configuration from LiteGraph */
widget: SimplifiedWidget<T>
/** The current value from parent component */
modelValue: T
/** Default value if modelValue is null/undefined */
defaultValue: T
/** Emit function from component setup */
emit: (event: 'update:modelValue', value: T) => void
/** Optional value transformer before sending to LiteGraph */
transform?: (value: U) => T
}
export interface UseWidgetValueReturn<
T extends WidgetValue = WidgetValue,
U = T
> {
/** Local value for immediate UI updates */
localValue: Ref<T>
/** Handler for user interactions */
onChange: (newValue: U) => void
}
/**
* Manages widget value synchronization with LiteGraph
*
* @example
* ```vue
* const { localValue, onChange } = useWidgetValue({
* widget: props.widget,
* modelValue: props.modelValue,
* defaultValue: ''
* })
* ```
*/
export function useWidgetValue<T extends WidgetValue = WidgetValue, U = T>({
widget,
modelValue,
defaultValue,
emit,
transform
}: UseWidgetValueOptions<T, U>): UseWidgetValueReturn<T, U> {
// Local value for immediate UI updates
const localValue = ref<T>(modelValue ?? defaultValue)
// Handle user changes
const onChange = (newValue: U) => {
// Handle different PrimeVue component signatures
let processedValue: T
if (transform) {
processedValue = transform(newValue)
} else {
// Ensure type safety - only cast when types are compatible
if (
typeof newValue === typeof defaultValue ||
newValue === null ||
newValue === undefined
) {
processedValue = (newValue ?? defaultValue) as T
} else {
console.warn(
`useWidgetValue: Type mismatch for widget ${widget.name}. Expected ${typeof defaultValue}, got ${typeof newValue}`
)
processedValue = defaultValue
}
}
// 1. Update local state for immediate UI feedback
localValue.value = processedValue
// 2. Emit to parent component
emit('update:modelValue', processedValue)
}
// Watch for external updates from LiteGraph
watch(
() => modelValue,
(newValue) => {
localValue.value = newValue ?? defaultValue
}
)
return {
localValue: localValue as Ref<T>,
onChange
}
}
/**
* Type-specific helper for string widgets
*/
export function useStringWidgetValue(
widget: SimplifiedWidget<string>,
modelValue: string,
emit: (event: 'update:modelValue', value: string) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: '',
emit,
transform: (value: string | undefined) => String(value || '') // Handle undefined from PrimeVue
})
}
/**
* Type-specific helper for number widgets
*/
export function useNumberWidgetValue(
widget: SimplifiedWidget<number>,
modelValue: number,
emit: (event: 'update:modelValue', value: number) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: 0,
emit,
transform: (value: number | number[]) => {
// Handle PrimeVue Slider which can emit number | number[]
if (Array.isArray(value)) {
return value.length > 0 ? value[0] ?? 0 : 0
}
return Number(value) || 0
}
})
}
/**
* Type-specific helper for boolean widgets
*/
export function useBooleanWidgetValue(
widget: SimplifiedWidget<boolean>,
modelValue: boolean,
emit: (event: 'update:modelValue', value: boolean) => void
) {
return useWidgetValue({
widget,
modelValue,
defaultValue: false,
emit,
transform: (value: boolean) => Boolean(value)
})
}

View File

@@ -1,5 +1,5 @@
import { useImagePreviewWidget } from '@/composables/widgets/useImagePreviewWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useImagePreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useImagePreviewWidget'
const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'

View File

@@ -1,6 +1,6 @@
import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import { useChatHistoryWidget } from '@/composables/widgets/useChatHistoryWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useChatHistoryWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useChatHistoryWidget'
const CHAT_HISTORY_WIDGET_NAME = '$$node-chat-history'

View File

@@ -1,5 +1,5 @@
import { useTextPreviewWidget } from '@/composables/widgets/useProgressTextWidget'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useTextPreviewWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useProgressTextWidget'
const TEXT_PREVIEW_WIDGET_NAME = '$$node-text-preview'

View File

@@ -282,6 +282,18 @@ export function useCoreCommands(): ComfyCommand[] {
app.canvas.setDirty(true, true)
}
},
{
id: 'Experimental.ToggleVueNodes',
label: () =>
`Experimental: ${
useSettingStore().get('Comfy.VueNodes.Enabled') ? 'Disable' : 'Enable'
} Vue Nodes`,
function: async () => {
const settingStore = useSettingStore()
const current = settingStore.get('Comfy.VueNodes.Enabled') ?? false
await settingStore.set('Comfy.VueNodes.Enabled', !current)
}
},
{
id: 'Comfy.Canvas.FitView',
icon: 'pi pi-expand',

View File

@@ -12,10 +12,9 @@ export enum ServerFeatureFlag {
}
/**
* Composable for reactive access to feature flags
* Composable for reactive access to server-side feature flags
*/
export function useFeatureFlags() {
// Create reactive state that tracks server feature flags
const flags = reactive({
get supportsPreviewMetadata() {
return api.getServerFeature(ServerFeatureFlag.SUPPORTS_PREVIEW_METADATA)
@@ -28,10 +27,8 @@ export function useFeatureFlags() {
}
})
// Create a reactive computed for any feature flag
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) => {
return computed(() => api.getServerFeature(featurePath, defaultValue))
}
const featureFlag = <T = unknown>(featurePath: string, defaultValue?: T) =>
computed(() => api.getServerFeature(featurePath, defaultValue))
return {
flags: readonly(flags),

View File

@@ -2,7 +2,8 @@ import { type Ref, computed, ref } from 'vue'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
export interface TemplateFilterOptions {
// @ts-expect-error unused (To be used later?)
interface TemplateFilterOptions {
searchQuery?: string
}

View File

@@ -0,0 +1,38 @@
/**
* Vue-related feature flags composable
* Manages local settings-driven flags and LiteGraph integration
*/
import { computed, watch } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { LiteGraph } from '../lib/litegraph/src/litegraph'
export const useVueFeatureFlags = () => {
const settingStore = useSettingStore()
const isVueNodesEnabled = computed(() => {
try {
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
} catch {
return false
}
})
// Whether Vue nodes should render
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
// Sync the Vue nodes flag with LiteGraph global settings
const syncVueNodesFlag = () => {
LiteGraph.vueNodesMode = isVueNodesEnabled.value
}
// Watch for changes and update LiteGraph immediately
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
return {
isVueNodesEnabled,
shouldRenderVueNodes,
syncVueNodesFlag
}
}

View File

@@ -952,5 +952,19 @@ export const CORE_SETTINGS: SettingParams[] = [
name: 'Release seen timestamp',
type: 'hidden',
defaultValue: 0
},
/**
* Vue Node System Settings
*/
{
id: 'Comfy.VueNodes.Enabled',
name: 'Enable Vue node rendering (hidden)',
type: 'hidden',
tooltip:
'Render nodes as Vue components instead of canvas. Hidden; toggle via Experimental keybinding.',
defaultValue: false,
experimental: true,
versionAdded: '1.27.1'
}
]

View File

@@ -1,5 +1,4 @@
import {
AutoLaunch,
CrossAttentionMethod,
CudaMalloc,
FloatingPointPrecision,
@@ -20,32 +19,6 @@ export interface ServerConfig<T> extends FormItem {
getValue?: (value: T) => Record<string, ServerConfigValue>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
// Launch behavior
{
id: 'auto-launch',
name: 'Automatically opens in the browser on startup',
category: ['Launch'],
type: 'combo',
options: Object.values(AutoLaunch),
defaultValue: AutoLaunch.Auto,
getValue: (value: AutoLaunch) => {
switch (value) {
case AutoLaunch.Auto:
return {}
case AutoLaunch.Enable:
return {
['auto-launch']: true
}
case AutoLaunch.Disable:
return {
['disable-auto-launch']: true
}
}
}
}
]
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
// Network settings
{

View File

@@ -0,0 +1,30 @@
/**
* Default colors for node slot types
* Mirrors LiteGraph's slot_default_color_by_type
*/
export const SLOT_TYPE_COLORS: Record<string, string> = {
number: '#AAD',
string: '#DCA',
boolean: '#DAA',
vec2: '#ADA',
vec3: '#ADA',
vec4: '#ADA',
color: '#DDA',
image: '#353',
latent: '#858',
conditioning: '#FFA',
control_net: '#F8F',
clip: '#FFD',
vae: '#F82',
model: '#B98',
'*': '#AAA' // Default color
}
/**
* Get the color for a slot type
*/
export function getSlotColor(type?: string | number | null): string {
if (!type) return SLOT_TYPE_COLORS['*']
const typeStr = String(type).toLowerCase()
return SLOT_TYPE_COLORS[typeStr] || SLOT_TYPE_COLORS['*']
}

View File

@@ -121,7 +121,7 @@ export class ManageGroupDialog extends ComfyDialog<HTMLDialogElement> {
getGroupData() {
this.groupNodeType = LiteGraph.registered_node_types[
`${PREFIX}${SEPARATOR}` + this.selectedGroup
] as LGraphNodeConstructor<LGraphNode>
] as unknown as LGraphNodeConstructor<LGraphNode>
this.groupNodeDef = this.groupNodeType.nodeData
this.groupData = GroupNodeHandler.getGroupData(this.groupNodeType)
}

View File

@@ -185,12 +185,3 @@ export interface LoaderManagerInterface {
dispose(): void
loadModel(url: string, originalFileName?: string): Promise<void>
}
export interface RecordingManagerInterface extends BaseManager {
startRecording(): Promise<void>
stopRecording(): void
hasRecording(): boolean
getRecordingDuration(): number
exportRecording(filename?: string): void
clearRecording(): void
}

View File

@@ -422,6 +422,7 @@ function getConfig(this: LGraphNode, widgetName: string) {
* @param node The node to convert the widget to an input slot for.
* @param widget The widget to convert to an input slot.
* @returns The input slot that was converted from the widget or undefined if the widget is not found.
* @knipIgnoreUnusedButUsedByCustomNodes
*/
export function convertToInput(
node: LGraphNode,

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

@@ -1,7 +1,7 @@
import type { Rectangle } from './infrastructure/Rectangle'
import type { CanvasColour, Rect } from './interfaces'
import { LiteGraph } from './litegraph'
import { LinkDirection, RenderShape, TitleMode } from './types/globalEnums'
import { RenderShape, TitleMode } from './types/globalEnums'
const ELLIPSIS = '\u2026'
const TWO_DOT_LEADER = '\u2025'
@@ -22,12 +22,7 @@ export enum SlotShape {
}
/** @see LinkDirection */
export enum SlotDirection {
Up = LinkDirection.UP,
Right = LinkDirection.RIGHT,
Down = LinkDirection.DOWN,
Left = LinkDirection.LEFT
}
export enum SlotDirection {}
export enum LabelPosition {
Left = 'left',

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'
@@ -277,9 +278,6 @@ export type KeysOfType<T, Match> = Exclude<
undefined
>
/** A new type that contains only the properties of T that are of type Match */
export type PickByType<T, Match> = { [P in keyof T]: Extract<T[P], Match> }
/** The names of all (optional) methods and functions in T */
export type MethodNames<T> = KeysOfType<T, ((...args: any) => any) | undefined>
@@ -471,6 +469,7 @@ export interface DefaultConnectionColors {
export interface ISubgraphInput extends INodeInputSlot {
_listenerController?: AbortController
_subgraphSlot: SubgraphInput
}
/**

View File

@@ -2,13 +2,13 @@ import type { ContextMenu } from './ContextMenu'
import type { LGraphNode } from './LGraphNode'
import { LiteGraphGlobal } from './LiteGraphGlobal'
import type { ConnectingLink, Point } from './interfaces'
import type { IContextMenuOptions, INodeSlot, Size } from './interfaces'
import type { IContextMenuOptions, Size } from './interfaces'
import { loadPolyfills } from './polyfills'
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()
@@ -22,8 +22,6 @@ loadPolyfills()
// Definitions by: NateScarlet <https://github.com/NateScarlet>
/** @deprecated Use {@link Point} instead. */
export type Vector2 = Point
/** @deprecated Use {@link Rect} instead. */
export type Vector4 = [number, number, number, number]
export interface IContextMenuItem {
content: string
@@ -46,14 +44,6 @@ export type ContextMenuEventListener = (
node: LGraphNode
) => boolean | void
export interface LinkReleaseContext {
node_to?: LGraphNode
node_from?: LGraphNode
slot_from: INodeSlot
type_filter_in?: string
type_filter_out?: string
}
export interface LinkReleaseContextExtended {
links: ConnectingLink[]
}
@@ -117,7 +107,6 @@ export type {
LinkNetwork,
LinkSegment,
MethodNames,
PickByType,
Point,
Positionable,
ReadonlyLinkNetwork,
@@ -134,7 +123,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

@@ -84,10 +84,6 @@ export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
return 'link' in slot
}
export function isINodeOutputSlot(slot: INodeSlot): slot is INodeOutputSlot {
return 'links' in slot
}
/**
* Type guard: Whether this input slot is attached to a widget.
* @param slot The slot to check.

View File

@@ -50,9 +50,6 @@ export interface CanvasMouseEvent
Readonly<CanvasPointerExtensions>,
LegacyMouseEvent {}
/** DragEvent with canvasX/Y and deltaX/Y properties */
export interface CanvasDragEvent extends DragEvent, CanvasPointerExtensions {}
export type CanvasEventDetail =
| GenericEventDetail
| GroupDoubleClickEventDetail

View File

@@ -89,9 +89,6 @@ export enum LGraphEventMode {
}
export enum EaseFunction {
LINEAR = 'linear',
EASE_IN_QUAD = 'easeInQuad',
EASE_OUT_QUAD = 'easeOutQuad',
EASE_IN_OUT_QUAD = 'easeInOutQuad'
}

View File

@@ -179,14 +179,6 @@ export interface ISerialisedGroup {
flags?: IGraphGroupFlags
}
export type TClipboardLink = [
targetRelativeIndex: number,
originSlot: number,
nodeRelativeIndex: number,
targetSlot: number,
targetNodeId: NodeId
]
/** Items copied from the canvas */
export interface ClipboardItems {
nodes?: ISerialisedNode[]
@@ -196,12 +188,6 @@ export interface ClipboardItems {
subgraphs?: ExportedSubgraph[]
}
/** @deprecated */
export interface IClipboardContents {
nodes?: ISerialisedNode[]
links?: TClipboardLink[]
}
export interface SerialisableReroute {
id: RerouteId
parentId?: RerouteId

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

@@ -1,8 +1,7 @@
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { LinkId } from '@/lib/litegraph/src/LLink'
import { parseSlotTypes } from '@/lib/litegraph/src/strings'
import type { ConnectingLink, ISlotType, Positionable } from '../interfaces'
import type { ISlotType, Positionable } from '../interfaces'
/**
* Creates a flat set of all positionable items by recursively iterating through all child items.
@@ -45,19 +44,6 @@ export function findFirstNode(
}
}
/** @returns `true` if the provided link ID is currently being dragged. */
export function isDraggingLink(
linkId: LinkId,
connectingLinks: ConnectingLink[] | null | undefined
): ConnectingLink | undefined {
if (connectingLinks == null) return
for (const connectingLink of connectingLinks) {
if (connectingLink.link == null) continue
if (linkId === connectingLink.link.id) return connectingLink
}
}
type FreeSlotResult<T extends { type: ISlotType }> =
| { index: number; slot: T }
| undefined

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

@@ -1,14 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
IBaseWidget,
IBooleanWidget,
IButtonWidget,
IComboWidget,
ICustomWidget,
IKnobWidget,
INumericWidget,
ISliderWidget,
IStringWidget,
IWidget,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
@@ -17,12 +10,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 +37,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 +95,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)
}
@@ -90,49 +123,9 @@ export function toConcreteWidget<TWidget extends IWidget | IBaseWidget>(
// #region Type Guards
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IButtonWidget}. */
export function isButtonWidget(widget: IBaseWidget): widget is IButtonWidget {
return widget.type === 'button'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IBooleanWidget}. */
export function isBooleanWidget(widget: IBaseWidget): widget is IBooleanWidget {
return widget.type === 'toggle'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ISliderWidget}. */
export function isSliderWidget(widget: IBaseWidget): widget is ISliderWidget {
return widget.type === 'slider'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IKnobWidget}. */
export function isKnobWidget(widget: IBaseWidget): widget is IKnobWidget {
return widget.type === 'knob'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IComboWidget}. */
export function isComboWidget(widget: IBaseWidget): widget is IComboWidget {
return widget.type === 'combo'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link INumericWidget}. */
export function isNumberWidget(widget: IBaseWidget): widget is INumericWidget {
return widget.type === 'number'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link IStringWidget}. */
export function isStringWidget(widget: IBaseWidget): widget is IStringWidget {
return widget.type === 'string'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ITextWidget}. */
export function isTextWidget(widget: IBaseWidget): widget is IStringWidget {
return widget.type === 'text'
}
/** Type guard: Narrow **from {@link IBaseWidget}** to {@link ICustomWidget}. */
export function isCustomWidget(widget: IBaseWidget): widget is ICustomWidget {
return widget.type === 'custom'
}
// #endregion Type Guards

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNodeProperties } from '../src/LGraphNodeProperties'
describe('LGraphNodeProperties', () => {
let mockNode: any
let mockGraph: any
beforeEach(() => {
mockGraph = {
trigger: vi.fn()
}
mockNode = {
id: 123,
title: 'Test Node',
flags: {},
graph: mockGraph
}
})
describe('constructor', () => {
it('should initialize with default tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
const tracked = propManager.getTrackedProperties()
expect(tracked).toHaveLength(2)
expect(tracked).toContain('title')
expect(tracked).toContain('flags.collapsed')
})
})
describe('property tracking', () => {
it('should track changes to existing properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'New Title'
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'title',
oldValue: 'Test Node',
newValue: 'New Title'
})
})
it('should track changes to nested properties', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
expect(mockGraph.trigger).toHaveBeenCalledWith('node:property:changed', {
nodeId: mockNode.id,
property: 'flags.collapsed',
oldValue: undefined,
newValue: true
})
})
it("should not emit events when value doesn't change", () => {
new LGraphNodeProperties(mockNode)
mockNode.title = 'Test Node' // Same value as original
expect(mockGraph.trigger).toHaveBeenCalledTimes(0)
})
it('should not emit events when node has no graph', () => {
mockNode.graph = null
new LGraphNodeProperties(mockNode)
// Should not throw
expect(() => {
mockNode.title = 'New Title'
}).not.toThrow()
})
})
describe('isTracked', () => {
it('should correctly identify tracked properties', () => {
const propManager = new LGraphNodeProperties(mockNode)
expect(propManager.isTracked('title')).toBe(true)
expect(propManager.isTracked('flags.collapsed')).toBe(true)
expect(propManager.isTracked('untracked')).toBe(false)
})
})
describe('serialization behavior', () => {
it('should not make non-existent properties enumerable', () => {
new LGraphNodeProperties(mockNode)
// flags.collapsed doesn't exist initially
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should make properties enumerable when set to non-default values', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(true)
})
it('should make properties non-enumerable when set back to undefined', () => {
new LGraphNodeProperties(mockNode)
mockNode.flags.collapsed = true
mockNode.flags.collapsed = undefined
const descriptor = Object.getOwnPropertyDescriptor(
mockNode.flags,
'collapsed'
)
expect(descriptor?.enumerable).toBe(false)
})
it('should keep existing properties enumerable', () => {
// title exists initially
const initialDescriptor = Object.getOwnPropertyDescriptor(
mockNode,
'title'
)
expect(initialDescriptor?.enumerable).toBe(true)
new LGraphNodeProperties(mockNode)
const afterDescriptor = Object.getOwnPropertyDescriptor(mockNode, 'title')
expect(afterDescriptor?.enumerable).toBe(true)
})
it('should only include non-undefined values in JSON.stringify', () => {
new LGraphNodeProperties(mockNode)
// Initially, flags.collapsed shouldn't appear
let json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
// After setting to true, it should appear
mockNode.flags.collapsed = true
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(true)
// After setting to false, it should still appear (false is not undefined)
mockNode.flags.collapsed = false
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBe(false)
// After setting back to undefined, it should disappear
mockNode.flags.collapsed = undefined
json = JSON.parse(JSON.stringify(mockNode))
expect(json.flags.collapsed).toBeUndefined()
})
})
})

View File

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

View File

@@ -169,140 +169,3 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
capture.cleanup()
}
})
/**
* Fixtures that test edge cases and error conditions.
* These may leave the system in an invalid state and should be used carefully.
*/
export interface EdgeCaseFixtures {
/** Subgraph with circular references (for testing recursion detection) */
circularSubgraph: {
rootGraph: LGraph
subgraphA: Subgraph
subgraphB: Subgraph
nodeA: SubgraphNode
nodeB: SubgraphNode
}
/** Deeply nested subgraphs approaching the theoretical limit */
deeplyNestedSubgraph: ReturnType<typeof createNestedSubgraphs>
/** Subgraph with maximum inputs and outputs */
maxIOSubgraph: Subgraph
}
/**
* Test with edge case fixtures. Use sparingly and with caution.
* These tests may intentionally create invalid states.
*/
export const edgeCaseTest = subgraphTest.extend<EdgeCaseFixtures>({
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
circularSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
const rootGraph = new LGraph()
// Create two subgraphs that will reference each other
const subgraphA = createTestSubgraph({
name: 'Subgraph A',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const subgraphB = createTestSubgraph({
name: 'Subgraph B',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
// Create instances (this doesn't create circular refs by itself)
const nodeA = createTestSubgraphNode(subgraphA, { pos: [100, 100] })
const nodeB = createTestSubgraphNode(subgraphB, { pos: [300, 100] })
// Add nodes to root graph
rootGraph.add(nodeA)
rootGraph.add(nodeB)
await use({
rootGraph,
subgraphA,
subgraphB,
nodeA,
nodeB
})
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
deeplyNestedSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a very deep nesting structure (but not exceeding MAX_NESTED_SUBGRAPHS)
const nested = createNestedSubgraphs({
depth: 50, // Deep but reasonable
nodesPerLevel: 1,
inputsPerSubgraph: 1,
outputsPerSubgraph: 1
})
await use(nested)
},
// @ts-expect-error TODO: Fix after merge - fixture use parameter type
// eslint-disable-next-line no-empty-pattern
maxIOSubgraph: async ({}, use: (value: unknown) => Promise<void>) => {
// Create a subgraph with many inputs and outputs
const inputs = Array.from({ length: 20 }, (_, i) => ({
name: `input_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const outputs = Array.from({ length: 20 }, (_, i) => ({
name: `output_${i}`,
type: i % 2 === 0 ? 'number' : ('string' as const)
}))
const subgraph = createTestSubgraph({
name: 'Max IO Subgraph',
inputs,
outputs,
nodeCount: 10
})
await use(subgraph)
}
})
/**
* Helper to verify fixture integrity.
* Use this in tests to ensure fixtures are properly set up.
*/
export function verifyFixtureIntegrity<T extends Record<string, unknown>>(
fixture: T,
expectedProperties: (keyof T)[]
): void {
for (const prop of expectedProperties) {
if (!(prop in fixture)) {
throw new Error(`Fixture missing required property: ${String(prop)}`)
}
if (fixture[prop] === undefined || fixture[prop] === null) {
throw new Error(`Fixture property ${String(prop)} is null or undefined`)
}
}
}
/**
* Creates a snapshot-friendly representation of a subgraph for testing.
* Useful for serialization tests and regression detection.
*/
export function createSubgraphSnapshot(subgraph: Subgraph) {
return {
id: subgraph.id,
name: subgraph.name,
inputCount: subgraph.inputs.length,
outputCount: subgraph.outputs.length,
nodeCount: subgraph.nodes.length,
linkCount: subgraph.links.size,
inputs: subgraph.inputs.map((i) => ({ name: i.name, type: i.type })),
outputs: subgraph.outputs.map((o) => ({ name: o.name, type: o.type })),
hasInputNode: !!subgraph.inputNode,
hasOutputNode: !!subgraph.outputNode
}
}

View File

@@ -382,76 +382,6 @@ export function createTestSubgraphData(
}
}
/**
* Creates a complex subgraph with multiple nodes and connections.
* Useful for testing realistic scenarios.
* @param nodeCount Number of internal nodes to create
* @returns Complex subgraph data structure
*/
export function createComplexSubgraphData(
nodeCount: number = 5
): ExportedSubgraph {
const nodes = []
const links: Record<
string,
{
id: number
origin_id: number
origin_slot: number
target_id: number
target_slot: number
type: string
}
> = {}
// Create internal nodes
for (let i = 0; i < nodeCount; i++) {
nodes.push({
id: i + 1, // Start from 1 to avoid conflicts with IO nodes
type: 'basic/test',
pos: [100 + i * 150, 200],
size: [120, 60],
inputs: [{ name: 'in', type: '*', link: null }],
outputs: [{ name: 'out', type: '*', links: [] }],
properties: { value: i },
flags: {},
mode: 0
})
}
// Create some internal links
for (let i = 0; i < nodeCount - 1; i++) {
const linkId = i + 1
links[linkId] = {
id: linkId,
origin_id: i + 1,
origin_slot: 0,
target_id: i + 2,
target_slot: 0,
type: '*'
}
}
return createTestSubgraphData({
// @ts-expect-error TODO: Fix after merge - nodes parameter type
nodes,
// @ts-expect-error TODO: Fix after merge - links parameter type
links,
inputs: [
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - input object type
{ name: 'input2', type: 'string', pos: [0, 1] }
],
outputs: [
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output1', type: 'number', pos: [0, 0] },
// @ts-expect-error TODO: Fix after merge - output object type
{ name: 'output2', type: 'string', pos: [0, 1] }
]
})
}
/**
* Creates an event capture system for testing event sequences.
* @param eventTarget The event target to monitor
@@ -493,39 +423,5 @@ export function createEventCapture<T = unknown>(
}
}
/**
* Utility to log subgraph structure for debugging tests.
* @param subgraph The subgraph to inspect
* @param label Optional label for the log output
*/
export function logSubgraphStructure(
subgraph: Subgraph,
label: string = 'Subgraph'
): void {
console.log(`\n=== ${label} Structure ===`)
console.log(`Name: ${subgraph.name}`)
console.log(`ID: ${subgraph.id}`)
console.log(`Inputs: ${subgraph.inputs.length}`)
console.log(`Outputs: ${subgraph.outputs.length}`)
console.log(`Nodes: ${subgraph.nodes.length}`)
console.log(`Links: ${subgraph.links.size}`)
if (subgraph.inputs.length > 0) {
console.log(
'Input details:',
subgraph.inputs.map((i) => ({ name: i.name, type: i.type }))
)
}
if (subgraph.outputs.length > 0) {
console.log(
'Output details:',
subgraph.outputs.map((o) => ({ name: o.name, type: o.type }))
)
}
console.log('========================\n')
}
// Re-export expect from vitest for convenience
export { expect } from 'vitest'

View File

@@ -56,6 +56,7 @@
"no": "No",
"cancel": "Cancel",
"close": "Close",
"dropYourFileOr": "Drop your file or",
"back": "Back",
"next": "Next",
"install": "Install",
@@ -151,7 +152,12 @@
"noAudioRecorded": "No audio recorded",
"nodesRunning": "nodes running",
"duplicate": "Duplicate",
"moreWorkflows": "More workflows"
"moreWorkflows": "More workflows",
"nodeRenderError": "Node Render Error",
"nodeContentError": "Node Content Error",
"nodeHeaderError": "Node Header Error",
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -1089,14 +1095,27 @@
"Next Opened Workflow": "Next Opened Workflow",
"Previous Opened Workflow": "Previous Opened Workflow",
"Toggle Search Box": "Toggle Search Box",
"Bottom Panel": "Bottom Panel",
"Toggle Bottom Panel": "Toggle Bottom Panel",
"Show Keybindings Dialog": "Show Keybindings Dialog",
"Toggle Terminal Bottom Panel": "Toggle Terminal Bottom Panel",
"Toggle Logs Bottom Panel": "Toggle Logs Bottom Panel",
"Toggle Essential Bottom Panel": "Toggle Essential Bottom Panel",
"Toggle View Controls Bottom Panel": "Toggle View Controls Bottom Panel",
"Toggle Focus Mode": "Toggle Focus Mode",
"Focus Mode": "Focus Mode",
"Model Library": "Model Library",
"Node Library": "Node Library",
"Queue Panel": "Queue Panel",
"Workflows": "Workflows",
"Toggle Model Library Sidebar": "Toggle Model Library Sidebar",
"Toggle Node Library Sidebar": "Toggle Node Library Sidebar",
"Toggle Queue Sidebar": "Toggle Queue Sidebar",
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar"
"Toggle Workflows Sidebar": "Toggle Workflows Sidebar",
"sideToolbar_modelLibrary": "sideToolbar.modelLibrary",
"sideToolbar_nodeLibrary": "sideToolbar.nodeLibrary",
"sideToolbar_queue": "sideToolbar.queue",
"sideToolbar_workflows": "sideToolbar.workflows"
},
"desktopMenu": {
"reinstall": "Reinstall",
@@ -1156,7 +1175,8 @@
"Credits": "Credits",
"API Nodes": "API Nodes",
"Notification Preferences": "Notification Preferences",
"3DViewer": "3DViewer"
"3DViewer": "3DViewer",
"Vue Nodes": "Vue Nodes"
},
"serverConfigItems": {
"listen": {

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validate workflows"
},
"Comfy_VueNodes_Enabled": {
"name": "Enable Vue node rendering",
"tooltip": "Render nodes as Vue components instead of canvas elements. Experimental feature."
},
"Comfy_VueNodes_Widgets": {
"name": "Enable Vue widgets",
"tooltip": "Render widgets as Vue components within Vue nodes."
},
"Comfy_WidgetControlMode": {
"name": "Widget control mode",
"tooltip": "Controls when widget values are updated (randomize/increment/decrement), either before the prompt is queued or after.",

View File

@@ -285,7 +285,7 @@
"label": "Alternar panel inferior de controles de vista"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Mostrar diálogo de atajos de teclado"
"label": "Mostrar diálogo de combinaciones de teclas"
},
"Workspace_ToggleFocusMode": {
"label": "Alternar Modo de Enfoque"

View File

@@ -310,6 +310,8 @@
"disabling": "Deshabilitando",
"dismiss": "Descartar",
"download": "Descargar",
"dropYourFileOr": "Suelta tu archivo o",
"duplicate": "Duplicar",
"edit": "Editar",
"empty": "Vacío",
"enableAll": "Habilitar todo",
@@ -805,6 +807,7 @@
"Show Model Selector (Dev)": "Mostrar selector de modelo (Desarrollo)",
"Show Settings Dialog": "Mostrar diálogo de configuración",
"Sign Out": "Cerrar sesión",
"Toggle Essential Bottom Panel": "Alternar panel inferior esencial",
"Toggle Bottom Panel": "Alternar panel inferior",
"Toggle Focus Mode": "Alternar modo de enfoque",
"Toggle Logs Bottom Panel": "Alternar panel inferior de registros",
@@ -814,7 +817,9 @@
"Toggle Search Box": "Alternar caja de búsqueda",
"Toggle Terminal Bottom Panel": "Alternar panel inferior de terminal",
"Toggle Theme (Dark/Light)": "Alternar tema (Oscuro/Claro)",
"Toggle View Controls Bottom Panel": "Alternar panel inferior de controles de vista",
"Toggle Workflows Sidebar": "Alternar barra lateral de los flujos de trabajo",
"Toggle the Custom Nodes Manager": "Alternar el Administrador de Nodos Personalizados",
"Toggle the Custom Nodes Manager Progress Bar": "Alternar la Barra de Progreso del Administrador de Nodos Personalizados",
"Undo": "Deshacer",
"Ungroup selected group nodes": "Desagrupar nodos de grupo seleccionados",
@@ -822,7 +827,19 @@
"Unload Models and Execution Cache": "Descargar modelos y caché de ejecución",
"Workflow": "Flujo de trabajo",
"Zoom In": "Acercar",
"Zoom Out": "Alejar"
"Zoom Out": "Alejar",
"Zoom to fit": "Ajustar al tamaño"
},
"minimap": {
"nodeColors": "Colores de nodos",
"renderBypassState": "Mostrar estado de omisión",
"renderErrorState": "Mostrar estado de error",
"showGroups": "Mostrar marcos/grupos",
"showLinks": "Mostrar enlaces",
"sideToolbar_modelLibrary": "sideToolbar.bibliotecaDeModelos",
"sideToolbar_nodeLibrary": "sideToolbar.bibliotecaDeNodos",
"sideToolbar_queue": "sideToolbar.cola",
"sideToolbar_workflows": "sideToolbar.flujosDeTrabajo"
},
"missingModelsDialog": {
"doNotAskAgain": "No mostrar esto de nuevo",
@@ -1138,6 +1155,7 @@
"UV": "UV",
"User": "Usuario",
"Validation": "Validación",
"Vue Nodes": "Nodos Vue",
"Window": "Ventana",
"Workflow": "Flujo de Trabajo"
},
@@ -1625,4 +1643,4 @@
"exportWorkflow": "Exportar flujo de trabajo",
"saveWorkflow": "Guardar flujo de trabajo"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Validar flujos de trabajo"
},
"Comfy_VueNodes_Enabled": {
"name": "Habilitar renderizado de nodos Vue",
"tooltip": "Renderiza los nodos como componentes Vue en lugar de elementos canvas. Función experimental."
},
"Comfy_VueNodes_Widgets": {
"name": "Habilitar widgets de Vue",
"tooltip": "Renderiza los widgets como componentes de Vue dentro de los nodos de Vue."
},
"Comfy_WidgetControlMode": {
"name": "Modo de control del widget",
"options": {

View File

@@ -310,6 +310,8 @@
"disabling": "Désactivation",
"dismiss": "Fermer",
"download": "Télécharger",
"dropYourFileOr": "Déposez votre fichier ou",
"duplicate": "Dupliquer",
"edit": "Modifier",
"empty": "Vide",
"enableAll": "Activer tout",
@@ -806,6 +808,8 @@
"Show Settings Dialog": "Afficher la boîte de dialogue des paramètres",
"Sign Out": "Se déconnecter",
"Toggle Essential Bottom Panel": "Basculer le panneau inférieur essentiel",
"Toggle Bottom Panel": "Basculer le panneau inférieur",
"Toggle Focus Mode": "Basculer le mode focus",
"Toggle Logs Bottom Panel": "Basculer le panneau inférieur des journaux",
"Toggle Model Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de modèles",
"Toggle Node Library Sidebar": "Afficher/Masquer la barre latérale de la bibliothèque de nœuds",
@@ -814,6 +818,7 @@
"Toggle Terminal Bottom Panel": "Basculer le panneau inférieur du terminal",
"Toggle Theme (Dark/Light)": "Basculer le thème (Sombre/Clair)",
"Toggle View Controls Bottom Panel": "Basculer le panneau inférieur des contrôles daffichage",
"Toggle Workflows Sidebar": "Afficher/Masquer la barre latérale des workflows",
"Toggle the Custom Nodes Manager": "Basculer le gestionnaire de nœuds personnalisés",
"Toggle the Custom Nodes Manager Progress Bar": "Basculer la barre de progression du gestionnaire de nœuds personnalisés",
"Undo": "Annuler",
@@ -822,7 +827,20 @@
"Unload Models and Execution Cache": "Décharger les modèles et le cache d'exécution",
"Workflow": "Flux de travail",
"Zoom In": "Zoom avant",
"Zoom Out": "Zoom arrière"
"Zoom Out": "Zoom arrière",
"Zoom to fit": "Ajuster à l'écran"
},
"minimap": {
"nodeColors": "Couleurs des nœuds",
"renderBypassState": "Afficher l'état de contournement",
"renderErrorState": "Afficher l'état d'erreur",
"showGroups": "Afficher les cadres/groupes",
"showLinks": "Afficher les liens",
"Zoom Out": "Zoom arrière",
"sideToolbar_modelLibrary": "Bibliothèque de modèles",
"sideToolbar_nodeLibrary": "Bibliothèque de nœuds",
"sideToolbar_queue": "File d'attente",
"sideToolbar_workflows": "Flux de travail"
},
"missingModelsDialog": {
"doNotAskAgain": "Ne plus afficher ce message",
@@ -1138,6 +1156,7 @@
"UV": "UV",
"User": "Utilisateur",
"Validation": "Validation",
"Vue Nodes": "Nœuds Vue",
"Window": "Fenêtre",
"Workflow": "Flux de Travail"
},
@@ -1625,4 +1644,4 @@
"exportWorkflow": "Exporter le flux de travail",
"saveWorkflow": "Enregistrer le flux de travail"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Valider les flux de travail"
},
"Comfy_VueNodes_Enabled": {
"name": "Activer le rendu des nœuds Vue",
"tooltip": "Rendre les nœuds comme composants Vue au lieu déléments canvas. Fonctionnalité expérimentale."
},
"Comfy_VueNodes_Widgets": {
"name": "Activer les widgets Vue",
"tooltip": "Rendre les widgets comme composants Vue à l'intérieur des nœuds Vue."
},
"Comfy_WidgetControlMode": {
"name": "Mode de contrôle du widget",
"options": {

View File

@@ -310,6 +310,8 @@
"disabling": "無効化",
"dismiss": "閉じる",
"download": "ダウンロード",
"dropYourFileOr": "ファイルをドロップするか",
"duplicate": "複製",
"edit": "編集",
"empty": "空",
"enableAll": "すべて有効にする",
@@ -807,10 +809,16 @@
"Sign Out": "サインアウト",
"Toggle Essential Bottom Panel": "エッセンシャル下部パネルの切り替え",
"Toggle Logs Bottom Panel": "ログ下部パネルの切り替え",
"Toggle Bottom Panel": "下部パネルの切り替え",
"Toggle Focus Mode": "フォーカスモードの切り替え",
"Toggle Model Library Sidebar": "モデルライブラリサイドバーを切り替え",
"Toggle Node Library Sidebar": "ノードライブラリサイドバーを切り替え",
"Toggle Queue Sidebar": "キューサイドバーを切り替え",
"Toggle Search Box": "検索ボックスの切り替え",
"Toggle Terminal Bottom Panel": "ターミナル下部パネルの切り替え",
"Toggle Theme (Dark/Light)": "テーマを切り替え(ダーク/ライト)",
"Toggle Workflows Sidebar": "ワークフローサイドバーを切り替え",
"Toggle the Custom Nodes Manager": "カスタムノードマネージャーを切り替え",
"Toggle the Custom Nodes Manager Progress Bar": "カスタムノードマネージャーの進行状況バーを切り替え",
"Undo": "元に戻す",
"Ungroup selected group nodes": "選択したグループノードのグループ解除",
@@ -818,7 +826,19 @@
"Unload Models and Execution Cache": "モデルと実行キャッシュのアンロード",
"Workflow": "ワークフロー",
"Zoom In": "ズームイン",
"Zoom Out": "ズームアウト"
"Zoom Out": "ズームアウト",
"Zoom to fit": "全体表示にズーム"
},
"minimap": {
"nodeColors": "ノードの色",
"renderBypassState": "バイパス状態を表示",
"renderErrorState": "エラー状態を表示",
"showGroups": "フレーム/グループを表示",
"showLinks": "リンクを表示",
"sideToolbar_modelLibrary": "モデルライブラリ",
"sideToolbar_nodeLibrary": "ノードライブラリ",
"sideToolbar_queue": "キュー",
"sideToolbar_workflows": "ワークフロー"
},
"missingModelsDialog": {
"doNotAskAgain": "再度表示しない",
@@ -1134,6 +1154,7 @@
"UV": "UV",
"User": "ユーザー",
"Validation": "検証",
"Vue Nodes": "Vueード",
"Window": "ウィンドウ",
"Workflow": "ワークフロー"
},
@@ -1621,4 +1642,4 @@
"exportWorkflow": "ワークフローをエクスポート",
"saveWorkflow": "ワークフローを保存"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "ワークフローを検証"
},
"Comfy_VueNodes_Enabled": {
"name": "Vueードレンダリングを有効化",
"tooltip": "ードをキャンバス要素の代わりにVueコンポーネントとしてレンダリングします。実験的な機能です。"
},
"Comfy_VueNodes_Widgets": {
"name": "Vueウィジェットを有効化",
"tooltip": "ウィジェットをVueード内のVueコンポーネントとしてレンダリングします。"
},
"Comfy_WidgetControlMode": {
"name": "ウィジェット制御モード",
"options": {

View File

@@ -282,7 +282,7 @@
"label": "필수 하단 패널 전환"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "보기 컨트롤 하단 패널 전환"
"label": " 컨트롤 하단 패널 전환"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "키 바인딩 대화상자 표시"

View File

@@ -310,6 +310,8 @@
"disabling": "비활성화 중",
"dismiss": "닫기",
"download": "다운로드",
"dropYourFileOr": "파일을 드롭하거나",
"duplicate": "복제",
"edit": "편집",
"empty": "비어 있음",
"enableAll": "모두 활성화",
@@ -805,16 +807,21 @@
"Show Model Selector (Dev)": "모델 선택기 표시 (개발자용)",
"Show Settings Dialog": "설정 대화상자 표시",
"Sign Out": "로그아웃",
"Toggle Essential Bottom Panel": "필수 하단 패널 전환",
"Toggle Bottom Panel": "하단 패널 전환",
"Toggle Focus Mode": "포커스 모드 전환",
"Toggle Logs Bottom Panel": "로그 하단 패널 전환",
"Toggle Model Library Sidebar": "모델 라이브러리 사이드바 전환",
"Toggle Node Library Sidebar": "노드 라이브러리 사이드바 전환",
"Toggle Queue Sidebar": "실행 대기열 사이드바 전환",
"Toggle Queue Sidebar": "대기열 사이드바 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Search Box": "검색 상자 전환",
"Toggle Terminal Bottom Panel": "터미널 하단 패널 전환",
"Toggle Theme (Dark/Light)": "테마 전환 (어두운/밝은)",
"Toggle View Controls Bottom Panel": "뷰 컨트롤 하단 패널 전환",
"Toggle Workflows Sidebar": "워크플로우 사이드바 전환",
"Toggle the Custom Nodes Manager": "커스텀 노드 매니저 전환",
"Toggle the Custom Nodes Manager Progress Bar": "커스텀 노드 매니저 진행률 표시줄 전환",
"Undo": "실행 취소",
"Ungroup selected group nodes": "선택한 그룹 노드 그룹 해제",
@@ -822,7 +829,19 @@
"Unload Models and Execution Cache": "모델 및 실행 캐시 언로드",
"Workflow": "워크플로",
"Zoom In": "확대",
"Zoom Out": "축소"
"Zoom Out": "축소",
"Zoom to fit": "화면에 맞추기"
},
"minimap": {
"nodeColors": "노드 색상",
"renderBypassState": "바이패스 상태 렌더링",
"renderErrorState": "에러 상태 렌더링",
"showGroups": "프레임/그룹 표시",
"showLinks": "링크 표시",
"sideToolbar_modelLibrary": "sideToolbar.모델 라이브러리",
"sideToolbar_nodeLibrary": "sideToolbar.노드 라이브러리",
"sideToolbar_queue": "sideToolbar.대기열",
"sideToolbar_workflows": "sideToolbar.워크플로우"
},
"missingModelsDialog": {
"doNotAskAgain": "다시 보지 않기",
@@ -1138,6 +1157,7 @@
"UV": "UV",
"User": "사용자",
"Validation": "검증",
"Vue Nodes": "Vue 노드",
"Window": "창",
"Workflow": "워크플로"
},
@@ -1625,4 +1645,4 @@
"exportWorkflow": "워크플로 내보내기",
"saveWorkflow": "워크플로 저장"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "워크플로 유효성 검사"
},
"Comfy_VueNodes_Enabled": {
"name": "Vue 노드 렌더링 활성화",
"tooltip": "노드를 캔버스 요소 대신 Vue 컴포넌트로 렌더링합니다. 실험적인 기능입니다."
},
"Comfy_VueNodes_Widgets": {
"name": "Vue 위젯 활성화",
"tooltip": "Vue 노드 내에서 위젯을 Vue 컴포넌트로 렌더링합니다."
},
"Comfy_WidgetControlMode": {
"name": "위젯 제어 모드",
"options": {

View File

@@ -282,7 +282,7 @@
"label": "Показать/скрыть основную нижнюю панель"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "Показать или скрыть нижнюю панель управления просмотром"
"label": "Показать/скрыть нижнюю панель управления просмотром"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "Показать диалог клавиш"

View File

@@ -310,6 +310,8 @@
"disabling": "Отключение",
"dismiss": "Закрыть",
"download": "Скачать",
"dropYourFileOr": "Перетащите ваш файл или",
"duplicate": "Дублировать",
"edit": "Редактировать",
"empty": "Пусто",
"enableAll": "Включить все",
@@ -807,10 +809,16 @@
"Sign Out": "Выйти",
"Toggle Essential Bottom Panel": "Показать/скрыть нижнюю панель основных элементов",
"Toggle Logs Bottom Panel": "Показать/скрыть нижнюю панель логов",
"Toggle Bottom Panel": "Переключить нижнюю панель",
"Toggle Focus Mode": "Переключить режим фокуса",
"Toggle Model Library Sidebar": "Показать/скрыть боковую панель библиотеки моделей",
"Toggle Node Library Sidebar": "Показать/скрыть боковую панель библиотеки узлов",
"Toggle Queue Sidebar": "Показать/скрыть боковую панель очереди",
"Toggle Search Box": "Переключить поисковую панель",
"Toggle Terminal Bottom Panel": "Показать/скрыть нижнюю панель терминала",
"Toggle Theme (Dark/Light)": "Переключение темы (Тёмная/Светлая)",
"Toggle View Controls Bottom Panel": "Показать/скрыть нижнюю панель элементов управления",
"Toggle Workflows Sidebar": "Показать/скрыть боковую панель рабочих процессов",
"Toggle the Custom Nodes Manager": "Переключить менеджер пользовательских узлов",
"Toggle the Custom Nodes Manager Progress Bar": "Переключить индикатор выполнения менеджера пользовательских узлов",
"Undo": "Отменить",
@@ -819,7 +827,19 @@
"Unload Models and Execution Cache": "Выгрузить модели и кэш выполнения",
"Workflow": "Рабочий процесс",
"Zoom In": "Увеличить",
"Zoom Out": "Уменьшить"
"Zoom Out": "Уменьшить",
"Zoom to fit": "Масштабировать по размеру"
},
"minimap": {
"nodeColors": "Цвета узлов",
"renderBypassState": "Отображать состояние обхода",
"renderErrorState": "Отображать состояние ошибки",
"showGroups": "Показать фреймы/группы",
"showLinks": "Показать связи",
"sideToolbar_modelLibrary": "sideToolbar.каталогМоделей",
"sideToolbar_nodeLibrary": "sideToolbar.каталогУзлов",
"sideToolbar_queue": "sideToolbar.очередь",
"sideToolbar_workflows": "sideToolbar.рабочиеПроцессы"
},
"missingModelsDialog": {
"doNotAskAgain": "Больше не показывать это",
@@ -1135,6 +1155,7 @@
"UV": "UV",
"User": "Пользователь",
"Validation": "Валидация",
"Vue Nodes": "Vue Nodes",
"Window": "Окно",
"Workflow": "Рабочий процесс"
},
@@ -1622,4 +1643,4 @@
"exportWorkflow": "Экспорт рабочего процесса",
"saveWorkflow": "Сохранить рабочий процесс"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "Проверка рабочих процессов"
},
"Comfy_VueNodes_Enabled": {
"name": "Включить рендеринг узлов через Vue",
"tooltip": "Отображать узлы как компоненты Vue вместо элементов canvas. Экспериментальная функция."
},
"Comfy_VueNodes_Widgets": {
"name": "Включить виджеты Vue",
"tooltip": "Отображать виджеты как компоненты Vue внутри узлов Vue."
},
"Comfy_WidgetControlMode": {
"name": "Режим управления виджетом",
"options": {

View File

@@ -310,6 +310,8 @@
"disabling": "停用中",
"dismiss": "關閉",
"download": "下載",
"dropYourFileOr": "拖放您的檔案或",
"duplicate": "複製",
"edit": "編輯",
"empty": "空",
"enableAll": "全部啟用",
@@ -811,6 +813,12 @@
"Toggle Terminal Bottom Panel": "切換終端機底部面板",
"Toggle Theme (Dark/Light)": "切換主題(深色/淺色)",
"Toggle View Controls Bottom Panel": "切換檢視控制底部面板",
"Toggle Bottom Panel": "切換下方面板",
"Toggle Focus Mode": "切換專注模式",
"Toggle Model Library Sidebar": "切換模型庫側邊欄",
"Toggle Node Library Sidebar": "切換節點庫側邊欄",
"Toggle Queue Sidebar": "切換佇列側邊欄",
"Toggle Workflows Sidebar": "切換工作流程側邊欄",
"Toggle the Custom Nodes Manager": "切換自訂節點管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切換自訂節點管理器進度條",
"Undo": "復原",
@@ -1135,6 +1143,7 @@
"UV": "UV",
"User": "使用者",
"Validation": "驗證",
"Vue Nodes": "Vue 節點",
"Window": "視窗",
"Workflow": "工作流程"
},
@@ -1622,4 +1631,4 @@
"exportWorkflow": "匯出工作流程",
"saveWorkflow": "儲存工作流程"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "驗證工作流程"
},
"Comfy_VueNodes_Enabled": {
"name": "啟用 Vue 節點渲染",
"tooltip": "將節點以 Vue 元件而非畫布元素方式渲染。實驗性功能。"
},
"Comfy_VueNodes_Widgets": {
"name": "啟用 Vue 小工具",
"tooltip": "在 Vue 節點中以 Vue 元件渲染小工具。"
},
"Comfy_WidgetControlMode": {
"name": "元件控制模式",
"options": {

View File

@@ -279,13 +279,13 @@
"label": "切换日志底部面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-essentials": {
"label": "切换基础底部面板"
"label": "切換基本下方面板"
},
"Workspace_ToggleBottomPanelTab_shortcuts-view-controls": {
"label": "切换视图控制底部面板"
"label": "切換檢視控制底部面板"
},
"Workspace_ToggleBottomPanel_Shortcuts": {
"label": "示快捷键对话框"
"label": "示快捷鍵對話框"
},
"Workspace_ToggleFocusMode": {
"label": "切换焦点模式"

View File

@@ -309,6 +309,7 @@
"disabling": "禁用中",
"dismiss": "关闭",
"download": "下载",
"dropYourFileOr": "拖放您的文件或",
"duplicate": "复制",
"edit": "编辑",
"empty": "空",
@@ -733,7 +734,7 @@
"instantTooltip": "工作流将会在生成完成后立即执行",
"interrupt": "取消当前任务",
"light": "淺色",
"manageExtensions": "管理擴充功能",
"manageExtensions": "管理扩展功能",
"onChange": "更改时",
"onChangeTooltip": "一旦进行更改,工作流将添加到执行队列",
"queue": "队列面板",
@@ -831,11 +832,14 @@
"Show Settings Dialog": "显示设置对话框",
"Sign Out": "退出登录",
"Toggle Essential Bottom Panel": "切换基础底部面板",
"Toggle Bottom Panel": "切换底部面板",
"Toggle Focus Mode": "切换专注模式",
"Toggle Logs Bottom Panel": "切换日志底部面板",
"Toggle Search Box": "切换搜索框",
"Toggle Terminal Bottom Panel": "切换终端底部面板",
"Toggle Theme (Dark/Light)": "切换主题(暗/亮)",
"Toggle View Controls Bottom Panel": "切换视图控制底部面板",
"Toggle Workflows Sidebar": "切换工作流侧边栏",
"Toggle the Custom Nodes Manager": "切换自定义节点管理器",
"Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条",
"Undo": "撤销",
@@ -852,7 +856,11 @@
"renderBypassState": "渲染绕过状态",
"renderErrorState": "渲染错误状态",
"showGroups": "显示框架/分组",
"showLinks": "显示连接"
"showLinks": "显示连接",
"sideToolbar_modelLibrary": "侧边工具栏.模型库",
"sideToolbar_nodeLibrary": "侧边工具栏.节点库",
"sideToolbar_queue": "侧边工具栏.队列",
"sideToolbar_workflows": "侧边工具栏.工作流"
},
"missingModelsDialog": {
"doNotAskAgain": "不再显示此消息",
@@ -1169,6 +1177,7 @@
"UV": "UV",
"User": "用户",
"Validation": "验证",
"Vue Nodes": "Vue 节点",
"Window": "窗口",
"Workflow": "工作流"
},
@@ -1687,4 +1696,4 @@
"showMinimap": "显示小地图",
"zoomToFit": "适合画面"
}
}
}

View File

@@ -343,6 +343,14 @@
"Comfy_Validation_Workflows": {
"name": "校验工作流"
},
"Comfy_VueNodes_Enabled": {
"name": "启用 Vue 节点渲染",
"tooltip": "将节点渲染为 Vue 组件,而不是画布元素。实验性功能。"
},
"Comfy_VueNodes_Widgets": {
"name": "启用Vue小部件",
"tooltip": "在Vue节点中将小部件渲染为Vue组件。"
},
"Comfy_WidgetControlMode": {
"name": "组件控制模式",
"options": {

View File

@@ -0,0 +1,589 @@
/**
* Litegraph Link Adapter
*
* Bridges the gap between litegraph's data model and the pure canvas renderer.
* Converts litegraph-specific types (LLink, LGraphNode, slots) into generic
* rendering data that can be consumed by the PathRenderer.
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
CanvasColour,
INodeInputSlot,
INodeOutputSlot,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
LinkDirection,
LinkMarkerShape,
LinkRenderType
} from '@/lib/litegraph/src/types/globalEnums'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import {
type ArrowShape,
CanvasPathRenderer,
type Direction,
type DragLinkData,
type LinkRenderData,
type RenderContext as PathRenderContext,
type Point,
type RenderMode
} from '@/renderer/core/canvas/pathRenderer'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds } from '@/renderer/core/layout/types'
export interface LinkRenderContext {
// Canvas settings
renderMode: LinkRenderType
connectionWidth: number
renderBorder: boolean
lowQuality: boolean
highQualityRender: boolean
scale: number
linkMarkerShape: LinkMarkerShape
renderConnectionArrows: boolean
// State
highlightedLinks: Set<string | number>
// Colors
defaultLinkColor: CanvasColour
linkTypeColors: Record<string, CanvasColour>
// Pattern for disabled links (optional)
disabledPattern?: CanvasPattern | null
}
export interface LinkRenderOptions {
color?: CanvasColour
flow?: boolean
skipBorder?: boolean
disabled?: boolean
}
export class LitegraphLinkAdapter {
private graph: LGraph
private pathRenderer: CanvasPathRenderer
public enableLayoutStoreWrites = true
constructor(graph: LGraph) {
this.graph = graph
this.pathRenderer = new CanvasPathRenderer()
}
/**
* Render a single link with all necessary data properly fetched
* Populates link.path for hit detection
*/
renderLink(
ctx: CanvasRenderingContext2D,
link: LLink,
context: LinkRenderContext,
options: LinkRenderOptions = {}
): void {
// Get nodes from graph
const sourceNode = this.graph.getNodeById(link.origin_id)
const targetNode = this.graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) {
console.warn(`Cannot render link ${link.id}: missing nodes`)
return
}
// Get slots from nodes
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) {
console.warn(`Cannot render link ${link.id}: missing slots`)
return
}
// Get positions using layout tree data if available
const startPos = getSlotPosition(
sourceNode,
link.origin_slot,
false // output
)
const endPos = getSlotPosition(
targetNode,
link.target_slot,
true // input
)
// Get directions from slots
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Convert to pure render data
const linkData = this.convertToLinkRenderData(
link,
{ x: startPos[0], y: startPos[1] },
{ x: endPos[0], y: endPos[1] },
startDir,
endDir,
options
)
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
link.path = path
// Update layout store when writes are enabled (event-driven path)
if (this.enableLayoutStoreWrites && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(startPos, endPos, linkData)
const centerPos = linkData.centerPos || {
x: (startPos[0] + endPos[0]) / 2,
y: (startPos[1] + endPos[1]) / 2
}
layoutStore.updateLinkLayout(link.id, {
id: link.id,
path: path,
bounds: bounds,
centerPos: centerPos,
sourceNodeId: String(link.origin_id),
targetNodeId: String(link.target_id),
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
// Also update segment layout for the whole link (null rerouteId means final segment)
layoutStore.updateLinkSegmentLayout(link.id, null, {
path: path,
bounds: bounds,
centerPos: centerPos
})
}
}
/**
* Convert litegraph link data to pure render format
*/
private convertToLinkRenderData(
link: LLink,
startPoint: Point,
endPoint: Point,
startDir: LinkDirection,
endDir: LinkDirection,
options: LinkRenderOptions
): LinkRenderData {
return {
id: String(link.id),
startPoint,
endPoint,
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: options.color
? String(options.color)
: link.color
? String(link.color)
: undefined,
type: link.type !== undefined ? String(link.type) : undefined,
flow: options.flow || false,
disabled: options.disabled || false
}
}
/**
* Convert LinkDirection enum to Direction string
*/
private convertDirection(dir: LinkDirection): Direction {
switch (dir) {
case LinkDirection.LEFT:
return 'left'
case LinkDirection.RIGHT:
return 'right'
case LinkDirection.UP:
return 'up'
case LinkDirection.DOWN:
return 'down'
default:
return 'right'
}
}
/**
* Convert LinkRenderContext to PathRenderContext
*/
private convertToPathRenderContext(
context: LinkRenderContext
): PathRenderContext {
// Match original arrow rendering conditions:
// Arrows only render when scale >= 0.6 AND highquality_render AND render_connection_arrows
const shouldShowArrows =
context.scale >= 0.6 &&
context.highQualityRender &&
context.renderConnectionArrows
// Only show center marker when not set to None
const shouldShowCenterMarker =
context.linkMarkerShape !== LinkMarkerShape.None
return {
style: {
mode: this.convertRenderMode(context.renderMode),
connectionWidth: context.connectionWidth,
borderWidth: context.renderBorder ? 4 : undefined,
arrowShape: this.convertArrowShape(context.linkMarkerShape),
showArrows: shouldShowArrows,
lowQuality: context.lowQuality,
// Center marker settings (matches original litegraph behavior)
showCenterMarker: shouldShowCenterMarker,
centerMarkerShape:
context.linkMarkerShape === LinkMarkerShape.Arrow
? 'arrow'
: 'circle',
highQuality: context.highQualityRender
},
colors: {
default: String(context.defaultLinkColor),
byType: this.convertColorMap(context.linkTypeColors),
highlighted: '#FFF'
},
patterns: {
disabled: context.disabledPattern
},
animation: {
time: LiteGraph.getTime() * 0.001
},
scale: context.scale,
highlightedIds: new Set(Array.from(context.highlightedLinks).map(String))
}
}
/**
* Convert LinkRenderType to RenderMode
*/
private convertRenderMode(mode: LinkRenderType): RenderMode {
switch (mode) {
case LinkRenderType.LINEAR_LINK:
return 'linear'
case LinkRenderType.STRAIGHT_LINK:
return 'straight'
case LinkRenderType.SPLINE_LINK:
default:
return 'spline'
}
}
/**
* Convert LinkMarkerShape to ArrowShape
*/
private convertArrowShape(shape: LinkMarkerShape): ArrowShape {
switch (shape) {
case LinkMarkerShape.Circle:
return 'circle'
case LinkMarkerShape.Arrow:
default:
return 'triangle'
}
}
/**
* Convert color map to ensure all values are strings
*/
private convertColorMap(
colors: Record<string, CanvasColour>
): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(colors)) {
result[key] = String(value)
}
return result
}
/**
* Apply spline offset to a point, mimicking original #addSplineOffset behavior
* Critically: does nothing for CENTER/NONE directions (no case for them)
*/
private applySplineOffset(
point: Point,
direction: LinkDirection,
distance: number
): void {
switch (direction) {
case LinkDirection.LEFT:
point.x -= distance
break
case LinkDirection.RIGHT:
point.x += distance
break
case LinkDirection.UP:
point.y -= distance
break
case LinkDirection.DOWN:
point.y += distance
break
// CENTER and NONE: no offset applied (original behavior)
}
}
/**
* Direct rendering method compatible with LGraphCanvas
* Converts data and delegates to pure renderer
*/
renderLinkDirect(
ctx: CanvasRenderingContext2D,
a: ReadOnlyPoint,
b: ReadOnlyPoint,
link: LLink | null,
skip_border: boolean,
flow: number | boolean | null,
color: CanvasColour | null,
start_dir: LinkDirection,
end_dir: LinkDirection,
context: LinkRenderContext,
extras: {
reroute?: Reroute
startControl?: ReadOnlyPoint
endControl?: ReadOnlyPoint
num_sublines?: number
disabled?: boolean
} = {}
): void {
// Apply same defaults as original renderLink
const startDir = start_dir || LinkDirection.RIGHT
const endDir = end_dir || LinkDirection.LEFT
// Convert flow to boolean
const flowBool = flow === true || (typeof flow === 'number' && flow > 0)
// Create LinkRenderData from direct parameters
const linkData: LinkRenderData = {
id: link ? String(link.id) : 'temp',
startPoint: { x: a[0], y: a[1] },
endPoint: { x: b[0], y: b[1] },
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: color !== null && color !== undefined ? String(color) : undefined,
type: link?.type !== undefined ? String(link.type) : undefined,
flow: flowBool,
disabled: extras.disabled || false
}
// Control points handling (spline mode):
// - Pre-refactor, the old renderLink honored a single provided control and
// derived the missing side via #addSplineOffset (CENTER => no offset).
// - Restore that behavior here so reroute segments render identically.
if (context.renderMode === LinkRenderType.SPLINE_LINK) {
const hasStartCtrl = !!extras.startControl
const hasEndCtrl = !!extras.endControl
// Compute distance once for offsets
const dist = Math.sqrt(
(b[0] - a[0]) * (b[0] - a[0]) + (b[1] - a[1]) * (b[1] - a[1])
)
const factor = 0.25
const cps: Point[] = []
if (hasStartCtrl && hasEndCtrl) {
// Both provided explicitly
cps.push(
{
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
},
{
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
)
linkData.controlPoints = cps
} else if (hasStartCtrl && !hasEndCtrl) {
// Start provided, derive end via direction offset (CENTER => no offset)
const start = {
x: a[0] + (extras.startControl![0] || 0),
y: a[1] + (extras.startControl![1] || 0)
}
const end = { x: b[0], y: b[1] }
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
} else if (!hasStartCtrl && hasEndCtrl) {
// End provided, derive start via direction offset (CENTER => no offset)
const start = { x: a[0], y: a[1] }
this.applySplineOffset(start, startDir, dist * factor)
const end = {
x: b[0] + (extras.endControl![0] || 0),
y: b[1] + (extras.endControl![1] || 0)
}
cps.push(start, end)
linkData.controlPoints = cps
} else {
// Neither provided: derive both from directions (CENTER => no offset)
const start = { x: a[0], y: a[1] }
const end = { x: b[0], y: b[1] }
this.applySplineOffset(start, startDir, dist * factor)
this.applySplineOffset(end, endDir, dist * factor)
cps.push(start, end)
linkData.controlPoints = cps
}
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Override skip_border if needed
if (skip_border) {
pathContext.style.borderWidth = undefined
}
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
const linkSegment = extras.reroute ?? link
if (linkSegment) {
linkSegment.path = path
// Copy calculated center position back to litegraph object
// This is needed for hit detection and menu interaction
if (linkData.centerPos) {
linkSegment._pos = linkSegment._pos || new Float32Array(2)
linkSegment._pos[0] = linkData.centerPos.x
linkSegment._pos[1] = linkData.centerPos.y
// Store center angle if calculated (for arrow markers)
if (linkData.centerAngle !== undefined) {
linkSegment._centreAngle = linkData.centerAngle
}
}
// Update layout store when writes are enabled (event-driven path)
if (this.enableLayoutStoreWrites && link && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(
[linkData.startPoint.x, linkData.startPoint.y] as ReadOnlyPoint,
[linkData.endPoint.x, linkData.endPoint.y] as ReadOnlyPoint,
linkData
)
const centerPos = linkData.centerPos || {
x: (linkData.startPoint.x + linkData.endPoint.x) / 2,
y: (linkData.startPoint.y + linkData.endPoint.y) / 2
}
// Update whole link layout (only if not a reroute segment)
if (!extras.reroute) {
layoutStore.updateLinkLayout(link.id, {
id: link.id,
path: path,
bounds: bounds,
centerPos: centerPos,
sourceNodeId: String(link.origin_id),
targetNodeId: String(link.target_id),
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
}
// Always update segment layout (for both regular links and reroute segments)
const rerouteId = extras.reroute ? extras.reroute.id : null
layoutStore.updateLinkSegmentLayout(link.id, rerouteId, {
path: path,
bounds: bounds,
centerPos: centerPos
})
}
}
}
/**
* Render a link being dragged from a slot to mouse position
* Used during link creation/reconnection
*/
renderDraggingLink(
ctx: CanvasRenderingContext2D,
fromNode: LGraphNode | null,
fromSlot: INodeOutputSlot | INodeInputSlot,
fromSlotIndex: number,
toPosition: ReadOnlyPoint,
context: LinkRenderContext,
options: {
fromInput?: boolean
color?: CanvasColour
disabled?: boolean
} = {}
): void {
if (!fromNode) return
// Get slot position using layout tree if available
const slotPos = getSlotPosition(
fromNode,
fromSlotIndex,
options.fromInput || false
)
if (!slotPos) return
// Get slot direction
const slotDir =
fromSlot.dir ||
(options.fromInput ? LinkDirection.LEFT : LinkDirection.RIGHT)
// Create drag data
const dragData: DragLinkData = {
fixedPoint: { x: slotPos[0], y: slotPos[1] },
fixedDirection: this.convertDirection(slotDir),
dragPoint: { x: toPosition[0], y: toPosition[1] },
color: options.color ? String(options.color) : undefined,
type: fromSlot.type !== undefined ? String(fromSlot.type) : undefined,
disabled: options.disabled || false,
fromInput: options.fromInput || false
}
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Hide center marker when dragging links
pathContext.style.showCenterMarker = false
// Render using pure renderer
this.pathRenderer.drawDraggingLink(ctx, dragData, pathContext)
}
/**
* Calculate bounding box for a link
* Includes padding for line width and control points
*/
private calculateLinkBounds(
startPos: ReadOnlyPoint,
endPos: ReadOnlyPoint,
linkData: LinkRenderData
): Bounds {
let minX = Math.min(startPos[0], endPos[0])
let maxX = Math.max(startPos[0], endPos[0])
let minY = Math.min(startPos[1], endPos[1])
let maxY = Math.max(startPos[1], endPos[1])
// Include control points if they exist (for spline links)
if (linkData.controlPoints) {
for (const cp of linkData.controlPoints) {
minX = Math.min(minX, cp.x)
maxX = Math.max(maxX, cp.x)
minY = Math.min(minY, cp.y)
maxY = Math.max(maxY, cp.y)
}
}
// Add padding for line width and hit tolerance
const padding = 20
return {
x: minX - padding,
y: minY - padding,
width: maxX - minX + 2 * padding,
height: maxY - minY + 2 * padding
}
}
}

View File

@@ -0,0 +1,283 @@
/**
* Slot Position Calculations
*
* Centralized utility for calculating input/output slot positions on nodes.
* This allows both litegraph nodes and the layout system to use the same
* calculation logic while providing their own position data.
*/
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type {
INodeInputSlot,
INodeOutputSlot,
INodeSlot,
Point,
ReadOnlyPoint
} from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { isWidgetInputSlot } from '@/lib/litegraph/src/node/slotUtils'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
export interface SlotPositionContext {
/** Node's X position in graph coordinates */
nodeX: number
/** Node's Y position in graph coordinates */
nodeY: number
/** Node's width */
nodeWidth: number
/** Node's height */
nodeHeight: number
/** Whether the node is collapsed */
collapsed: boolean
/** Collapsed width (if applicable) */
collapsedWidth?: number
/** Node constructor's slot_start_y offset */
slotStartY?: number
/** Node's input slots */
inputs: INodeInputSlot[]
/** Node's output slots */
outputs: INodeOutputSlot[]
/** Node's widgets (for widget slot detection) */
widgets?: Array<{ name?: string }>
}
/**
* Calculate the position of an input slot in graph coordinates
* @param context Node context containing position and slot data
* @param slot The input slot index
* @returns Position of the input slot center in graph coordinates
*/
export function calculateInputSlotPos(
context: SlotPositionContext,
slot: number
): Point {
const input = context.inputs[slot]
if (!input) return [context.nodeX, context.nodeY]
return calculateInputSlotPosFromSlot(context, input)
}
/**
* Calculate the position of an input slot in graph coordinates
* @param context Node context containing position and slot data
* @param input The input slot object
* @returns Position of the input slot center in graph coordinates
*/
export function calculateInputSlotPosFromSlot(
context: SlotPositionContext,
input: INodeInputSlot
): Point {
const { nodeX, nodeY, collapsed } = context
// Handle collapsed nodes
if (collapsed) {
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX, nodeY - halfTitle]
}
// Handle hard-coded positions
const { pos } = input
if (pos) return [nodeX + pos[0], nodeY + pos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
if (isWidgetInputSlot(input)) {
// Widget slot - pass the slot object
return calculateVueSlotPosition(context, true, input, -1)
} else {
// Regular slot - find its index in default vertical inputs
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, true, input, slotIndex)
}
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
const defaultVerticalInputs = getDefaultVerticalInputs(context)
const slotIndex = defaultVerticalInputs.indexOf(input)
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
return [nodeX + offsetX, nodeY + slotY + nodeOffsetY]
}
/**
* Calculate the position of an output slot in graph coordinates
* @param context Node context containing position and slot data
* @param slot The output slot index
* @returns Position of the output slot center in graph coordinates
*/
export function calculateOutputSlotPos(
context: SlotPositionContext,
slot: number
): Point {
const { nodeX, nodeY, nodeWidth, collapsed, collapsedWidth, outputs } =
context
// Handle collapsed nodes
if (collapsed) {
const width = collapsedWidth || LiteGraph.NODE_COLLAPSED_WIDTH
const halfTitle = LiteGraph.NODE_TITLE_HEIGHT * 0.5
return [nodeX + width, nodeY - halfTitle]
}
const outputSlot = outputs[slot]
if (!outputSlot) return [nodeX + nodeWidth, nodeY]
// Handle hard-coded positions
const outputPos = outputSlot.pos
if (outputPos) return [nodeX + outputPos[0], nodeY + outputPos[1]]
// Check if we should use Vue positioning
if (LiteGraph.vueNodesMode) {
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
if (slotIndex !== -1) {
return calculateVueSlotPosition(context, false, outputSlot, slotIndex)
}
}
// Default vertical slots
const offsetX = LiteGraph.NODE_SLOT_HEIGHT * 0.5
const nodeOffsetY = context.slotStartY || 0
const defaultVerticalOutputs = getDefaultVerticalOutputs(context)
const slotIndex = defaultVerticalOutputs.indexOf(outputSlot)
const slotY = (slotIndex + 0.7) * LiteGraph.NODE_SLOT_HEIGHT
// TODO: Why +1?
return [nodeX + nodeWidth + 1 - offsetX, nodeY + slotY + nodeOffsetY]
}
/**
* Get slot position using layout tree if available, fallback to node's position
* Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param node The LGraphNode
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
export function getSlotPosition(
node: LGraphNode,
slotIndex: number,
isInput: boolean
): ReadOnlyPoint {
// Try to get precise position from slot layout (DOM-registered)
const slotKey = getSlotKey(String(node.id), slotIndex, isInput)
const slotLayout = layoutStore.getSlotLayout(slotKey)
if (slotLayout) {
return [slotLayout.position.x, slotLayout.position.y]
}
// Fallback: derive position from node layout tree and slot model
const nodeLayout = layoutStore.getNodeLayoutRef(String(node.id)).value
if (nodeLayout) {
// Create context from layout tree data
const context: SlotPositionContext = {
nodeX: nodeLayout.position.x,
nodeY: nodeLayout.position.y,
nodeWidth: nodeLayout.size.width,
nodeHeight: nodeLayout.size.height,
collapsed: node.flags.collapsed || false,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
// Use helper to calculate position
return isInput
? calculateInputSlotPos(context, slotIndex)
: calculateOutputSlotPos(context, slotIndex)
}
// Fallback to node's own methods if layout not available
return isInput ? node.getInputPos(slotIndex) : node.getOutputPos(slotIndex)
}
/**
* Get the inputs that are not positioned with absolute coordinates
*/
function getDefaultVerticalInputs(
context: SlotPositionContext
): INodeInputSlot[] {
return context.inputs.filter(
(slot) => !slot.pos && !(context.widgets?.length && isWidgetInputSlot(slot))
)
}
/**
* Get the outputs that are not positioned with absolute coordinates
*/
function getDefaultVerticalOutputs(
context: SlotPositionContext
): INodeOutputSlot[] {
return context.outputs.filter((slot) => !slot.pos)
}
/**
* Calculate slot position using Vue node dimensions.
* This method uses the COMFY_VUE_NODE_DIMENSIONS constants to match Vue component rendering.
* @param context Node context
* @param isInput Whether this is an input slot (true) or output slot (false)
* @param slot The slot object (for widget detection)
* @param slotIndex The index of the slot in the appropriate array
* @returns The [x, y] position of the slot center in graph coordinates
*/
function calculateVueSlotPosition(
context: SlotPositionContext,
isInput: boolean,
slot: INodeSlot,
slotIndex: number
): Point {
const { nodeX, nodeY, nodeWidth, widgets } = context
const dimensions = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.components
const spacing = LiteGraph.COMFY_VUE_NODE_DIMENSIONS.spacing
let slotCenterY: number
// IMPORTANT: LiteGraph's node position (nodeY) is at the TOP of the body (below the header)
// The header is rendered ABOVE this position at negative Y coordinates
// So we need to adjust for the difference between LiteGraph's header (30px) and Vue's header (34px)
const headerDifference =
dimensions.HEADER_HEIGHT - LiteGraph.NODE_TITLE_HEIGHT
if (isInput && isWidgetInputSlot(slot as INodeInputSlot)) {
// Widget input slot - calculate based on widget position
// Count regular (non-widget) input slots
const regularInputCount = getDefaultVerticalInputs(context).length
// Find widget index
const widgetIndex =
widgets?.findIndex(
(w) => w.name === (slot as INodeInputSlot).widget?.name
) ?? 0
// Y position relative to the node body top (not the header)
slotCenterY =
headerDifference +
regularInputCount * dimensions.SLOT_HEIGHT +
(regularInputCount > 0 ? spacing.BETWEEN_SLOTS_AND_BODY : 0) +
widgetIndex *
(dimensions.STANDARD_WIDGET_HEIGHT + spacing.BETWEEN_WIDGETS) +
dimensions.STANDARD_WIDGET_HEIGHT / 2
} else {
// Regular slot (input or output)
// Slots start at the top of the body, but we need to account for Vue's larger header
slotCenterY =
headerDifference +
slotIndex * dimensions.SLOT_HEIGHT +
dimensions.SLOT_HEIGHT / 2
}
// Calculate X position
// Input slots: 10px from left edge (center of 20x20 connector)
// Output slots: 10px from right edge (center of 20x20 connector)
const slotCenterX = isInput ? 10 : nodeWidth - 10
return [nodeX + slotCenterX, nodeY + slotCenterY]
}

View File

@@ -0,0 +1,820 @@
/**
* Path Renderer
*
* Pure canvas2D rendering utility with no framework dependencies.
* Renders bezier curves, straight lines, and linear connections between points.
* Supports arrows, flow animations, and returns Path2D objects for hit detection.
* Can be reused in any canvas-based project without modification.
*/
export interface Point {
x: number
y: number
}
export type Direction = 'left' | 'right' | 'up' | 'down'
export type RenderMode = 'spline' | 'straight' | 'linear'
export type ArrowShape = 'triangle' | 'circle' | 'square'
export interface LinkRenderData {
id: string
startPoint: Point
endPoint: Point
startDirection: Direction
endDirection: Direction
color?: string
type?: string
controlPoints?: Point[]
flow?: boolean
disabled?: boolean
// Optional multi-segment support
segments?: Array<{
start: Point
end: Point
controlPoints?: Point[]
}>
// Center point storage (for hit detection and menu)
centerPos?: Point
centerAngle?: number
}
export interface RenderStyle {
mode: RenderMode
connectionWidth: number
borderWidth?: number
arrowShape?: ArrowShape
showArrows?: boolean
lowQuality?: boolean
// Center marker properties
showCenterMarker?: boolean
centerMarkerShape?: 'circle' | 'arrow'
highQuality?: boolean
}
export interface RenderColors {
default: string
byType: Record<string, string>
highlighted: string
}
export interface RenderContext {
style: RenderStyle
colors: RenderColors
patterns?: {
disabled?: CanvasPattern | null
}
animation?: {
time: number // Seconds for flow animation
}
scale?: number // Canvas scale for quality adjustments
highlightedIds?: Set<string>
}
export interface DragLinkData {
/** Fixed end - the slot being dragged from */
fixedPoint: Point
fixedDirection: Direction
/** Moving end - follows mouse */
dragPoint: Point
dragDirection?: Direction
/** Visual properties */
color?: string
type?: string
disabled?: boolean
/** Whether dragging from input (reverse direction) */
fromInput?: boolean
}
export class CanvasPathRenderer {
/**
* Draw a link between two points
* Returns a Path2D object for hit detection
*/
drawLink(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext
): Path2D {
const path = new Path2D()
// Determine final color
const isHighlighted = context.highlightedIds?.has(link.id) ?? false
const color = this.determineLinkColor(link, context, isHighlighted)
// Save context state
ctx.save()
// Apply disabled pattern if needed
if (link.disabled && context.patterns?.disabled) {
ctx.strokeStyle = context.patterns.disabled
} else {
ctx.strokeStyle = color
}
// Set line properties
ctx.lineWidth = context.style.connectionWidth
ctx.lineJoin = 'round'
// Draw border if needed
if (context.style.borderWidth && !context.style.lowQuality) {
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth + context.style.borderWidth,
'rgba(0,0,0,0.5)'
)
}
// Draw main link
this.drawLinkPath(
ctx,
path,
link,
context,
context.style.connectionWidth,
color
)
// Calculate and store center position
this.calculateCenterPoint(link, context)
// Draw arrows if needed
if (context.style.showArrows) {
this.drawArrows(ctx, link, context, color)
}
// Draw center marker if needed (for link menu interaction)
if (
context.style.showCenterMarker &&
context.scale &&
context.scale >= 0.6 &&
context.style.highQuality
) {
this.drawCenterMarker(ctx, link, context, color)
}
// Draw flow animation if needed
if (link.flow && context.animation) {
this.drawFlowAnimation(ctx, path, link, context)
}
ctx.restore()
return path
}
private determineLinkColor(
link: LinkRenderData,
context: RenderContext,
isHighlighted: boolean
): string {
if (isHighlighted) {
return context.colors.highlighted
}
if (link.color) {
return link.color
}
if (link.type && context.colors.byType[link.type]) {
return context.colors.byType[link.type]
}
return context.colors.default
}
private drawLinkPath(
ctx: CanvasRenderingContext2D,
path: Path2D,
link: LinkRenderData,
context: RenderContext,
lineWidth: number,
color: string
): void {
ctx.strokeStyle = color
ctx.lineWidth = lineWidth
const start = link.startPoint
const end = link.endPoint
// Build the path based on render mode
if (context.style.mode === 'linear') {
this.buildLinearPath(
path,
start,
end,
link.startDirection,
link.endDirection
)
} else if (context.style.mode === 'straight') {
this.buildStraightPath(
path,
start,
end,
link.startDirection,
link.endDirection
)
} else {
// Spline mode (default)
this.buildSplinePath(
path,
start,
end,
link.startDirection,
link.endDirection,
link.controlPoints
)
}
ctx.stroke(path)
}
private buildLinearPath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): void {
// Match original litegraph LINEAR_LINK mode with 4-point path
const l = 15 // offset distance for control points
const innerA = { x: start.x, y: start.y }
const innerB = { x: end.x, y: end.y }
// Apply directional offsets to create control points
switch (startDir) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (endDir) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Draw 4-point path: start -> innerA -> innerB -> end
path.moveTo(start.x, start.y)
path.lineTo(innerA.x, innerA.y)
path.lineTo(innerB.x, innerB.y)
path.lineTo(end.x, end.y)
}
private buildStraightPath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): void {
// Match original STRAIGHT_LINK implementation with l=10 offset
const l = 10 // offset distance matching original
const innerA = { x: start.x, y: start.y }
const innerB = { x: end.x, y: end.y }
// Apply directional offsets to match original behavior
switch (startDir) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (endDir) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Calculate midpoint using innerA/innerB positions (matching original)
const midX = (innerA.x + innerB.x) * 0.5
// Build path: start -> innerA -> (midX, innerA.y) -> (midX, innerB.y) -> innerB -> end
path.moveTo(start.x, start.y)
path.lineTo(innerA.x, innerA.y)
path.lineTo(midX, innerA.y)
path.lineTo(midX, innerB.y)
path.lineTo(innerB.x, innerB.y)
path.lineTo(end.x, end.y)
}
private buildSplinePath(
path: Path2D,
start: Point,
end: Point,
startDir: Direction,
endDir: Direction,
controlPoints?: Point[]
): void {
path.moveTo(start.x, start.y)
// Calculate control points if not provided
const controls =
controlPoints || this.calculateControlPoints(start, end, startDir, endDir)
if (controls.length >= 2) {
// Cubic bezier
path.bezierCurveTo(
controls[0].x,
controls[0].y,
controls[1].x,
controls[1].y,
end.x,
end.y
)
} else if (controls.length === 1) {
// Quadratic bezier
path.quadraticCurveTo(controls[0].x, controls[0].y, end.x, end.y)
} else {
// Fallback to linear
path.lineTo(end.x, end.y)
}
}
private calculateControlPoints(
start: Point,
end: Point,
startDir: Direction,
endDir: Direction
): Point[] {
const dist = Math.sqrt(
Math.pow(end.x - start.x, 2) + Math.pow(end.y - start.y, 2)
)
const controlDist = Math.max(30, dist * 0.25)
// Calculate control point offsets based on direction
const startControl = this.getDirectionOffset(startDir, controlDist)
const endControl = this.getDirectionOffset(endDir, controlDist)
return [
{ x: start.x + startControl.x, y: start.y + startControl.y },
{ x: end.x + endControl.x, y: end.y + endControl.y }
]
}
private getDirectionOffset(direction: Direction, distance: number): Point {
switch (direction) {
case 'left':
return { x: -distance, y: 0 }
case 'right':
return { x: distance, y: 0 }
case 'up':
return { x: 0, y: -distance }
case 'down':
return { x: 0, y: distance }
}
}
private drawArrows(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!context.style.showArrows) return
// Render arrows at 0.25 and 0.75 positions along the path (matching original)
const positions = [0.25, 0.75]
for (const t of positions) {
// Compute arrow position and angle
const posA = this.computeConnectionPoint(link, t, context)
const posB = this.computeConnectionPoint(link, t + 0.01, context) // slightly ahead for angle
const angle = Math.atan2(posB.y - posA.y, posB.x - posA.x)
// Draw arrow triangle (matching original shape)
const transform = ctx.getTransform()
ctx.translate(posA.x, posA.y)
ctx.rotate(angle)
ctx.fillStyle = color
ctx.beginPath()
ctx.moveTo(-5, -3)
ctx.lineTo(0, +7)
ctx.lineTo(+5, -3)
ctx.fill()
ctx.setTransform(transform)
}
}
/**
* Compute a point along the link path at position t (0 to 1)
* For backward compatibility with original litegraph, this always uses
* bezier calculation with spline offsets, regardless of render mode.
* This ensures arrow positions match the original implementation.
*/
private computeConnectionPoint(
link: LinkRenderData,
t: number,
_context: RenderContext
): Point {
const { startPoint, endPoint, startDirection, endDirection } = link
// Match original behavior: always use bezier math with spline offsets
// regardless of render mode (for arrow position compatibility)
const dist = Math.sqrt(
Math.pow(endPoint.x - startPoint.x, 2) +
Math.pow(endPoint.y - startPoint.y, 2)
)
const factor = 0.25
// Create control points with spline offsets (matching original #addSplineOffset)
const pa = { x: startPoint.x, y: startPoint.y }
const pb = { x: endPoint.x, y: endPoint.y }
// Apply spline offsets based on direction
switch (startDirection) {
case 'left':
pa.x -= dist * factor
break
case 'right':
pa.x += dist * factor
break
case 'up':
pa.y -= dist * factor
break
case 'down':
pa.y += dist * factor
break
}
switch (endDirection) {
case 'left':
pb.x -= dist * factor
break
case 'right':
pb.x += dist * factor
break
case 'up':
pb.y -= dist * factor
break
case 'down':
pb.y += dist * factor
break
}
// Calculate bezier point (matching original computeConnectionPoint)
const c1 = (1 - t) * (1 - t) * (1 - t)
const c2 = 3 * ((1 - t) * (1 - t)) * t
const c3 = 3 * (1 - t) * (t * t)
const c4 = t * t * t
return {
x: c1 * startPoint.x + c2 * pa.x + c3 * pb.x + c4 * endPoint.x,
y: c1 * startPoint.y + c2 * pa.y + c3 * pb.y + c4 * endPoint.y
}
}
private drawFlowAnimation(
ctx: CanvasRenderingContext2D,
_path: Path2D,
link: LinkRenderData,
context: RenderContext
): void {
if (!context.animation) return
// Match original implementation: render 5 moving circles along the path
const time = context.animation.time
const linkColor = this.determineLinkColor(link, context, false)
ctx.save()
ctx.fillStyle = linkColor
// Draw 5 circles at different positions along the path
for (let i = 0; i < 5; ++i) {
// Calculate position along path (0 to 1), with time-based animation
const f = (time + i * 0.2) % 1
const flowPos = this.computeConnectionPoint(link, f, context)
// Draw circle at this position
ctx.beginPath()
ctx.arc(flowPos.x, flowPos.y, 5, 0, 2 * Math.PI)
ctx.fill()
}
ctx.restore()
}
/**
* Utility to find a point on a bezier curve (for hit detection)
*/
findPointOnBezier(
t: number,
p0: Point,
p1: Point,
p2: Point,
p3: Point
): Point {
const mt = 1 - t
const mt2 = mt * mt
const mt3 = mt2 * mt
const t2 = t * t
const t3 = t2 * t
return {
x: mt3 * p0.x + 3 * mt2 * t * p1.x + 3 * mt * t2 * p2.x + t3 * p3.x,
y: mt3 * p0.y + 3 * mt2 * t * p1.y + 3 * mt * t2 * p2.y + t3 * p3.y
}
}
/**
* Draw a link being dragged from a slot to the mouse position
* Returns a Path2D object for potential hit detection
*/
drawDraggingLink(
ctx: CanvasRenderingContext2D,
dragData: DragLinkData,
context: RenderContext
): Path2D {
// Create LinkRenderData from drag data
// When dragging from input, swap the points/directions
const linkData: LinkRenderData = dragData.fromInput
? {
id: 'dragging',
startPoint: dragData.dragPoint,
endPoint: dragData.fixedPoint,
startDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
endDirection: dragData.fixedDirection,
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
: {
id: 'dragging',
startPoint: dragData.fixedPoint,
endPoint: dragData.dragPoint,
startDirection: dragData.fixedDirection,
endDirection:
dragData.dragDirection ||
this.getOppositeDirection(dragData.fixedDirection),
color: dragData.color,
type: dragData.type,
disabled: dragData.disabled
}
// Use standard link drawing
return this.drawLink(ctx, linkData, context)
}
/**
* Get the opposite direction (for drag preview)
*/
private getOppositeDirection(direction: Direction): Direction {
switch (direction) {
case 'left':
return 'right'
case 'right':
return 'left'
case 'up':
return 'down'
case 'down':
return 'up'
}
}
/**
* Get the center point of a link (useful for labels, debugging)
*/
getLinkCenter(link: LinkRenderData): Point {
// For now, simple midpoint
// Could be enhanced to find actual curve midpoint
return {
x: (link.startPoint.x + link.endPoint.x) / 2,
y: (link.startPoint.y + link.endPoint.y) / 2
}
}
/**
* Calculate and store the center point and angle of a link
* Mimics the original litegraph center point calculation
*/
private calculateCenterPoint(
link: LinkRenderData,
context: RenderContext
): void {
const { startPoint, endPoint, controlPoints } = link
if (
context.style.mode === 'spline' &&
controlPoints &&
controlPoints.length >= 2
) {
// For spline mode, find point at t=0.5 on the bezier curve
const centerPos = this.findPointOnBezier(
0.5,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerPos = centerPos
// Calculate angle for arrow marker (point slightly past center)
if (context.style.centerMarkerShape === 'arrow') {
const justPastCenter = this.findPointOnBezier(
0.51,
startPoint,
controlPoints[0],
controlPoints[1],
endPoint
)
link.centerAngle = Math.atan2(
justPastCenter.y - centerPos.y,
justPastCenter.x - centerPos.x
)
}
} else if (context.style.mode === 'linear') {
// For linear mode, calculate midpoint between control points (matching original)
const l = 15 // Same offset as buildLinearPath
const innerA = { x: startPoint.x, y: startPoint.y }
const innerB = { x: endPoint.x, y: endPoint.y }
// Apply same directional offsets as buildLinearPath
switch (link.startDirection) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (link.endDirection) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
link.centerPos = {
x: (innerA.x + innerB.x) * 0.5,
y: (innerA.y + innerB.y) * 0.5
}
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(innerB.y - innerA.y, innerB.x - innerA.x)
}
} else if (context.style.mode === 'straight') {
// For straight mode, match original STRAIGHT_LINK center calculation
const l = 10 // Same offset as buildStraightPath
const innerA = { x: startPoint.x, y: startPoint.y }
const innerB = { x: endPoint.x, y: endPoint.y }
// Apply same directional offsets as buildStraightPath
switch (link.startDirection) {
case 'left':
innerA.x -= l
break
case 'right':
innerA.x += l
break
case 'up':
innerA.y -= l
break
case 'down':
innerA.y += l
break
}
switch (link.endDirection) {
case 'left':
innerB.x -= l
break
case 'right':
innerB.x += l
break
case 'up':
innerB.y -= l
break
case 'down':
innerB.y += l
break
}
// Calculate center using midX and average of innerA/innerB y positions
const midX = (innerA.x + innerB.x) * 0.5
link.centerPos = {
x: midX,
y: (innerA.y + innerB.y) * 0.5
}
if (context.style.centerMarkerShape === 'arrow') {
const diff = innerB.y - innerA.y
if (Math.abs(diff) < 4) {
link.centerAngle = 0
} else if (diff > 0) {
link.centerAngle = Math.PI * 0.5
} else {
link.centerAngle = -(Math.PI * 0.5)
}
}
} else {
// Fallback to simple midpoint
link.centerPos = this.getLinkCenter(link)
if (context.style.centerMarkerShape === 'arrow') {
link.centerAngle = Math.atan2(
endPoint.y - startPoint.y,
endPoint.x - startPoint.x
)
}
}
}
/**
* Draw the center marker on a link (for menu interaction)
* Matches the original litegraph center marker rendering
*/
private drawCenterMarker(
ctx: CanvasRenderingContext2D,
link: LinkRenderData,
context: RenderContext,
color: string
): void {
if (!link.centerPos) return
ctx.beginPath()
if (
context.style.centerMarkerShape === 'arrow' &&
link.centerAngle !== undefined
) {
const transform = ctx.getTransform()
ctx.translate(link.centerPos.x, link.centerPos.y)
ctx.rotate(link.centerAngle)
// The math is off, but it currently looks better in chromium (from original)
ctx.moveTo(-3.2, -5)
ctx.lineTo(7, 0)
ctx.lineTo(-3.2, 5)
ctx.setTransform(transform)
} else {
// Default to circle
ctx.arc(link.centerPos.x, link.centerPos.y, 5, 0, Math.PI * 2)
}
// Apply disabled pattern or color
if (link.disabled && context.patterns?.disabled) {
const { fillStyle, globalAlpha } = ctx
ctx.fillStyle = context.patterns.disabled
ctx.globalAlpha = 0.75
ctx.fill()
ctx.globalAlpha = globalAlpha
ctx.fillStyle = fillStyle
} else {
ctx.fillStyle = color
ctx.fill()
}
}
}

View File

@@ -0,0 +1,50 @@
/**
* Layout System Constants
*
* Centralized configuration values for the layout system.
* These values control spatial indexing, performance, and behavior.
*/
import { LayoutSource } from '@/renderer/core/layout/types'
/**
* QuadTree configuration for spatial indexing
*/
export const QUADTREE_CONFIG = {
/** Default bounds for the QuadTree - covers a large canvas area */
DEFAULT_BOUNDS: {
x: -10000,
y: -10000,
width: 20000,
height: 20000
},
/** Maximum tree depth to prevent excessive subdivision */
MAX_DEPTH: 6,
/** Maximum items per node before subdivision */
MAX_ITEMS_PER_NODE: 4
} as const
/**
* Performance and optimization settings
*/
export const PERFORMANCE_CONFIG = {
/** RAF-based change detection interval (roughly 60fps) */
CHANGE_DETECTION_INTERVAL: 16,
/** Spatial query cache TTL in milliseconds */
SPATIAL_CACHE_TTL: 1000,
/** Maximum cache size for spatial queries */
SPATIAL_CACHE_MAX_SIZE: 100,
/** Batch update delay in milliseconds */
BATCH_UPDATE_DELAY: 4
} as const
/**
* Actor and source identifiers
*/
export const ACTOR_CONFIG = {
/** Prefix for auto-generated actor IDs */
USER_PREFIX: 'user-',
/** Length of random suffix for actor IDs */
ID_LENGTH: 9,
/** Default source when not specified */
DEFAULT_SOURCE: LayoutSource.External
} as const

View File

@@ -0,0 +1,340 @@
/**
* Layout Mutations - Simplified Direct Operations
*
* Provides a clean API for layout operations that are CRDT-ready.
* Operations are synchronous and applied directly to the store.
*/
import log from 'loglevel'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
LayoutSource,
type LinkId,
type NodeLayout,
type Point,
type RerouteId,
type Size
} from '@/renderer/core/layout/types'
const logger = log.getLogger('LayoutMutations')
export interface LayoutMutations {
// Single node operations (synchronous, CRDT-ready)
moveNode(nodeId: NodeId, position: Point): void
resizeNode(nodeId: NodeId, size: Size): void
setNodeZIndex(nodeId: NodeId, zIndex: number): void
// Node lifecycle operations
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
// Link operations
createLink(
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void
deleteLink(linkId: LinkId): void
// Reroute operations
createReroute(
rerouteId: RerouteId,
position: Point,
parentId?: LinkId,
linkIds?: LinkId[]
): void
deleteReroute(rerouteId: RerouteId): void
moveReroute(
rerouteId: RerouteId,
position: Point,
previousPosition: Point
): void
// Stacking operations
bringNodeToFront(nodeId: NodeId): void
// Source tracking
setSource(source: LayoutSource): void
setActor(actor: string): void
}
/**
* Composable for accessing layout mutations with clean destructuring API
*/
export function useLayoutMutations(): LayoutMutations {
/**
* Set the current mutation source
*/
const setSource = (source: LayoutSource): void => {
layoutStore.setSource(source)
}
/**
* Set the current actor (for CRDT)
*/
const setActor = (actor: string): void => {
layoutStore.setActor(actor)
}
/**
* Move a node to a new position
*/
const moveNode = (nodeId: NodeId, position: Point): void => {
const normalizedNodeId = String(nodeId)
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId: normalizedNodeId,
position,
previousPosition: existing.position,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Resize a node
*/
const resizeNode = (nodeId: NodeId, size: Size): void => {
const normalizedNodeId = String(nodeId)
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId: normalizedNodeId,
size,
previousSize: existing.size,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Set node z-index
*/
const setNodeZIndex = (nodeId: NodeId, zIndex: number): void => {
const normalizedNodeId = String(nodeId)
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId: normalizedNodeId,
zIndex,
previousZIndex: existing.zIndex,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new node
*/
const createNode = (nodeId: NodeId, layout: Partial<NodeLayout>): void => {
const normalizedNodeId = String(nodeId)
const fullLayout: NodeLayout = {
id: normalizedNodeId,
position: layout.position ?? { x: 0, y: 0 },
size: layout.size ?? { width: 200, height: 100 },
zIndex: layout.zIndex ?? 0,
visible: layout.visible ?? true,
bounds: {
x: layout.position?.x ?? 0,
y: layout.position?.y ?? 0,
width: layout.size?.width ?? 200,
height: layout.size?.height ?? 100
}
}
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId: normalizedNodeId,
layout: fullLayout,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a node
*/
const deleteNode = (nodeId: NodeId): void => {
const normalizedNodeId = String(nodeId)
const existing = layoutStore.getNodeLayoutRef(normalizedNodeId).value
if (!existing) return
layoutStore.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId: normalizedNodeId,
previousLayout: existing,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Bring a node to the front (highest z-index)
*/
const bringNodeToFront = (nodeId: NodeId): void => {
// Get all nodes to find the highest z-index
const allNodes = layoutStore.getAllNodes().value
let maxZIndex = 0
for (const [, layout] of allNodes) {
if (layout.zIndex > maxZIndex) {
maxZIndex = layout.zIndex
}
}
// Set this node's z-index to be one higher than the current max
setNodeZIndex(nodeId, maxZIndex + 1)
}
/**
* Create a new link
*/
const createLink = (
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void => {
// Normalize node IDs to strings for layout store consistency
const normalizedSourceNodeId = String(sourceNodeId)
const normalizedTargetNodeId = String(targetNodeId)
logger.debug('Creating link:', {
linkId,
from: `${normalizedSourceNodeId}[${sourceSlot}]`,
to: `${normalizedTargetNodeId}[${targetSlot}]`
})
layoutStore.applyOperation({
type: 'createLink',
entity: 'link',
linkId,
sourceNodeId: normalizedSourceNodeId,
sourceSlot,
targetNodeId: normalizedTargetNodeId,
targetSlot,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a link
*/
const deleteLink = (linkId: LinkId): void => {
logger.debug('Deleting link:', linkId)
layoutStore.applyOperation({
type: 'deleteLink',
entity: 'link',
linkId,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new reroute
*/
const createReroute = (
rerouteId: RerouteId,
position: Point,
parentId?: LinkId,
linkIds: LinkId[] = []
): void => {
logger.debug('Creating reroute:', {
rerouteId,
position,
parentId,
linkCount: linkIds.length
})
layoutStore.applyOperation({
type: 'createReroute',
entity: 'reroute',
rerouteId,
position,
parentId,
linkIds,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a reroute
*/
const deleteReroute = (rerouteId: RerouteId): void => {
logger.debug('Deleting reroute:', rerouteId)
layoutStore.applyOperation({
type: 'deleteReroute',
entity: 'reroute',
rerouteId,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Move a reroute
*/
const moveReroute = (
rerouteId: RerouteId,
position: Point,
previousPosition: Point
): void => {
logger.debug('Moving reroute:', {
rerouteId,
from: previousPosition,
to: position
})
layoutStore.applyOperation({
type: 'moveReroute',
entity: 'reroute',
rerouteId,
position,
previousPosition,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
return {
setSource,
setActor,
moveNode,
resizeNode,
setNodeZIndex,
createNode,
deleteNode,
bringNodeToFront,
createLink,
deleteLink,
createReroute,
deleteReroute,
moveReroute
}
}

View File

@@ -0,0 +1,75 @@
/**
* Slot Registration
*
* Handles registration of slot layouts with the layout store for hit testing.
* This module manages the state mutation side of slot layout management,
* while pure calculations are handled separately in SlotCalculations.ts.
*/
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
type SlotPositionContext,
calculateInputSlotPos,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
/**
* Register slot layout with the layout store for hit testing
* @param nodeId The node ID
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @param position The slot position in graph coordinates
*/
export function registerSlotLayout(
nodeId: string,
slotIndex: number,
isInput: boolean,
position: Point
): void {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Calculate bounds for the slot using LiteGraph's standard slot height
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
const halfSize = slotSize / 2
const slotLayout: SlotLayout = {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: position[0], y: position[1] },
bounds: {
x: position[0] - halfSize,
y: position[1] - halfSize,
width: slotSize,
height: slotSize
}
}
layoutStore.updateSlotLayout(slotKey, slotLayout)
}
/**
* Register all slots for a node
* @param nodeId The node ID
* @param context The slot position context
*/
export function registerNodeSlots(
nodeId: string,
context: SlotPositionContext
): void {
// Register input slots
context.inputs.forEach((_, index) => {
const position = calculateInputSlotPos(context, index)
registerSlotLayout(nodeId, index, true, position)
})
// Register output slots
context.outputs.forEach((_, index) => {
const position = calculateOutputSlotPos(context, index)
registerSlotLayout(nodeId, index, false, position)
})
}

View File

@@ -0,0 +1,40 @@
/**
* Slot identifier utilities for consistent slot key generation and parsing
*
* Provides a centralized interface for slot identification across the layout system
*
* @TODO Replace this concatenated string with root cause fix
*/
export interface SlotIdentifier {
nodeId: string
index: number
isInput: boolean
}
/**
* Generate a unique key for a slot
* Format: "{nodeId}-{in|out}-{index}"
*/
export function getSlotKey(identifier: SlotIdentifier): string
export function getSlotKey(
nodeId: string,
index: number,
isInput: boolean
): string
export function getSlotKey(
nodeIdOrIdentifier: string | SlotIdentifier,
index?: number,
isInput?: boolean
): string {
if (typeof nodeIdOrIdentifier === 'object') {
const { nodeId, index, isInput } = nodeIdOrIdentifier
return `${nodeId}-${isInput ? 'in' : 'out'}-${index}`
}
if (index === undefined || isInput === undefined) {
throw new Error('Missing required parameters for slot key generation')
}
return `${nodeIdOrIdentifier}-${isInput ? 'in' : 'out'}-${index}`
}

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