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

* [feat] Add core Vue widget infrastructure

- SimplifiedWidget interface for Vue-based node widgets
- widgetPropFilter utility with component-specific exclusion lists
- Removes DOM manipulation and positioning concerns
- Provides clean API for value binding and prop filtering

* [feat] Add Vue widget registry system

- Complete widget type enum with all 15 widget types
- Component mapping registry for dynamic widget rendering
- Helper function for type-safe widget component resolution

* [feat] Add Vue input widgets

- WidgetInputText: Single-line text input with InputText component
- WidgetTextarea: Multi-line text input with Textarea component
- WidgetSlider: Numeric range input with Slider component
- WidgetToggleSwitch: Boolean toggle with ToggleSwitch component

* [feat] Add Vue selection widgets

- WidgetSelect: Dropdown selection with Select component
- WidgetMultiSelect: Multiple selection with MultiSelect component
- WidgetSelectButton: Button group selection with SelectButton component
- WidgetTreeSelect: Hierarchical selection with TreeSelect component

* [feat] Add Vue visual widgets

- WidgetColorPicker: Color selection with ColorPicker component
- WidgetImage: Single image display with Image component
- WidgetImageCompare: Before/after comparison with ImageCompare component
- WidgetGalleria: Image gallery/carousel with Galleria component
- WidgetChart: Data visualization with Chart component

* [feat] Add Vue action widgets

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

* [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>

* Update locales [skip ci]

* Fix TransformPane pos/size (#4826)

* Update locales [skip ci]

* refactor(litegraph): decouple render-time state from models for reroutes and links\n\nIntroduce RenderedLinkSegment; compute reroute render params without mutating model; render into ephemeral segments instead of writing to Reroute/LLink.

* Revert "refactor(litegraph): decouple render-time state from models for reroutes and links\n\nIntroduce RenderedLinkSegment; compute reroute render params without mutating model; render into ephemeral segments instead of writing to Reroute/LLink."

This reverts commit d7ed1d36ed.

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

* Add vue node feature flag (#4927)

* feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

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

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

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

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

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

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

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

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

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

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

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

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

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

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

* refactor: Extract services and split composables for better organization

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

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

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

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

* [chore] Extract link rendering out of LGraphCanvas (#4994)

* feat: Implement CRDT-based layout system for Vue nodes

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

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

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

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

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

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

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

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

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

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

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

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

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

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

* refactor: Extract services and split composables for better organization

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

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

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

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Remove unused methods in LGLA

* Extract slot position calculations to shared utility

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

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

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

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

* [fix] Restore original link rendering behavior after refactor

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

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

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

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

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

Fixes #Issue-Number

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>

* refactor: Reorganize layout system into new renderer architecture (#5071)

- Move layout system to renderer/core/layout/
  - Store, operations, adapters, and sync modules organized clearly
  - Merged layoutTypes.ts and layoutOperations.ts into single types.ts
- Move canvas rendering to renderer/core/canvas/
  - LiteGraph-specific code in litegraph/ subdirectory
  - PathRenderer at canvas level
- Move spatial indexing to renderer/core/spatial/
- Move Vue node composables to renderer/extensions/vue-nodes/
- Update all import paths throughout codebase
- Apply consistent naming (renderer vs rendering)

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

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

Co-authored-by: Claude <noreply@anthropic.com>

* [refactor] Reorganize Vue nodes to domain-driven design architecture (#5085)

* refactor: Reorganize Vue nodes system to domain-driven design architecture

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

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

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

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

Performance tests are inherently flaky on CI due to variable system
performance. This test should only run locally like the other
performance tests.

* fix: Initialize Vue node manager when first node is added to empty graph (#5086)

* fix: Initialize Vue node manager when first node is added to empty graph

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

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

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

* fix: Add TODO comment for reactive graph mutations observer

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

🤖 Generated with Claude Code

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

* [bugfix] Fix Vue node import path after refactoring

Update LGraphNode import path from old location to new domain-driven architecture path.

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

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

* Remove layout logging noise from console (#5101)

- Remove loglevel import and logger setup from LayoutStore
- Remove all logger.debug() calls throughout LayoutStore
- Remove localStorage debug check for layout operations
- Remove unused DEBUG_CONFIG from layout constants
- Clean up console noise while preserving error handling

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

Co-authored-by: Claude <noreply@anthropic.com>

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

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

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

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

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

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

This reverts commit 70fbfd0f5e.

* [bugfix] Fix link center dot hit detection when marker is disabled (#5135)

When linkMarkerShape is set to None, clicks were still being detected on the invisible center dot. This fix adds proper checks to skip hit detection when the center marker is disabled.

Fixes center dot hit detection issue

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

Co-authored-by: Claude <noreply@anthropic.com>

* [bugfix] Hide center dot when dragging links (#5133)

The center dot/marker on links should not be visible when the user is dragging links to connect nodes. This fix ensures the center marker is hidden during link dragging operations.

🤖 Generated with Claude Code

Co-authored-by: Claude <noreply@anthropic.com>

* feat: v3 style of node body (#5169)

* feat: v3 style of node body

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

* fix: review's issues

* fix: review's issue

* Update lockfile after rebase (#5254)

* chore: Update pnpm-lock.yaml after rebase

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

* Fix SelectionOverlay rebase issue (#5255)

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

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

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

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

* Deduplicate i18n keys from rebasing (#5257)

* fix: Add missing comma in zh locale JSON

Fixes JSON syntax error introduced during rebase.

* dedup i18n keys

* fix: Restore simplified Chinese translation for Toggle Workflows Sidebar

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

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

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

* Fix lodash import (#5269)

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

* [feat] TransformPane - Viewport synchronization layer for Vue nodes (#4304)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benceruleanlu@proton.me>
Co-authored-by: github-actions <github-actions@github.com>

* Update locales [skip ci]

* Update locales [skip ci]

* Add vue node feature flag (#4927)

* feat: Implement CRDT-based layout system for Vue nodes (#4959)

* feat: Implement CRDT-based layout system for Vue nodes

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

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

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

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

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

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

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

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

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

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

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

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

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

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

* refactor: Extract services and split composables for better organization

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

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

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

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>

* [chore] Extract link rendering out of LGraphCanvas (#4994)

* feat: Implement CRDT-based layout system for Vue nodes

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

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

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

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

* style: Apply linter fixes to layout system

* fix: Remove unnecessary README files and revert services README

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

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

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

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

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

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

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

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

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

* refactor: Extract services and split composables for better organization

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

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

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

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Revert "Totally not scuffed renderer and adapter"

This reverts commit 2b9d83efb8.

* Revert "Remove slots from layoutTypes"

This reverts commit 18f78ff786.

* Reapply "Add node slots to layout tree"

This reverts commit 236fecb549.

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* docs: Replace architecture docs with comprehensive ADR

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

* Add node slots to layout tree

* Revert "Add node slots to layout tree"

This reverts commit 460493a620.

* Remove slots from layoutTypes

* Totally not scuffed renderer and adapter

* Remove unused methods in LGLA

* Extract slot position calculations to shared utility

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

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

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

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

* [fix] Restore original link rendering behavior after refactor

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

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

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

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

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

Fixes #Issue-Number

---------

Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: Claude <noreply@anthropic.com>

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

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

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

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

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

This reverts commit 70fbfd0f5e.

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

- Implement slot registration for all nodes (Vue and LiteGraph)
- Add spatial indexes for slots and reroutes to improve hit detection performance
- Register slots when nodes are drawn via new registerSlots() method
- Update LayoutStore to use spatial indexing for O(log n) queries instead of O(n)

Resolves #5125

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

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

* relocate slot update to layoutstore

* Revert "relocate slot update to layoutstore"

This reverts commit 0b17ef148bdded35cb231bef25b8d5c77dc14c1f.

* add useSlotLayoutSync

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

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

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

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

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

* Scuffed diff, change to dirty later

* Fix reroute move desync

* Terrible reroute fixes

* Use LinkId for LinkLayout

* refactor: Remove unused duplicate layout type files

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

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

🤖 Generated with Claude Code

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

* refactor: Extract layout source strings into LayoutSource enum

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

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

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

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

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

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

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

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

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

* Fix initial link seeding

* fix: Fix reroute hit detection and type consistency issues

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

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

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

* Add debug logs

* Add missing reroute path

* cleanup

* feat: Implement event-driven link layout sync

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

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

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

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

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

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

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

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

🤖 Generated with Claude Code

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

* Remove unused files

* Remove duplicated markdown file

* Remove duplicated files and address knip concerns

* Remove outdated test

* warning comment

* Update test snapshots

---------

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

* chore: Empty commit to trigger CI checks

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

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

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

* feat: localization fields (#5318)

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

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

* Remove IMAGE widget

* Remove IMAGE widget test expectations

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

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

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

* feat: widget input text style

* feat: widget select button style

* feat: the selection style of LGraphNode

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

* feat: placeholder

* fix: filter multi select options

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

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

* refactor: v3 ui slots connection dots

* fix: use the new useTemplateRef

* fix: slot dark-theme border and hover styles

---------

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

* add explicit typing on component IDs (#5352)

* Remove IMAGE widget cont. (#5355)

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

* remove DI

* remove layoutMutations property on LGraph

* remove layout mutations property from LGraph snapshot

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

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

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

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

Fixes test failure: ENOENT error for single_save_image_node.json

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

* Enable VueNodes

* Use KSampler not save image

* Update test expectations [skip ci]

* remove crdt ADR (moved to separate PR)

* update adr README

* removed unused IMAGE widget enum value

* remove all unused (knip pass)

* remove debug overlay panel

* simplify unit tests

* change name "transformPaneEnabled" => "isVueNodesEnabled"

* remove debug viewport visualizer

* remove debug viewport visualizer prop

* remove outdated README

* skip all vue node operations if feature is turned off

* remove debug logging and setting

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

* cleanup comments

* cleanup comments

* add missing translations

* use camelCase for all non-component files

* remove debug viewport test

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

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

* remove redundant comment

* use camelcase for layoutStore filename

* removed unused type guards

* simplify widget registration

* move back test that was mistakenly moved

* remove unused typeguards

* removed unused node def type guards

---------

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

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

View File

@@ -0,0 +1,229 @@
/**
* DOM-based slot registration with performance optimization
*
* Measures the actual DOM position of a Vue slot connector and registers it
* into the LayoutStore so hit-testing and link rendering use the true position.
*
* Performance strategy:
* - Cache slot offset relative to node (avoids DOM reads during drag)
* - No measurements during pan/zoom (camera transforms don't change canvas coords)
* - Batch DOM reads via requestAnimationFrame
* - Only remeasure on structural changes (resize, collapse, LOD)
*/
import {
type Ref,
type WatchStopHandle,
nextTick,
onMounted,
onUnmounted,
ref,
watch
} from 'vue'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Point as LayoutPoint } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
export type TransformState = {
screenToCanvas: (p: LayoutPoint) => LayoutPoint
}
// Shared RAF queue for batching measurements
const measureQueue = new Set<() => void>()
let rafId: number | null = null
// Track mounted components to prevent execution on unmounted ones
const mountedComponents = new WeakSet<object>()
function scheduleMeasurement(fn: () => void) {
measureQueue.add(fn)
if (rafId === null) {
rafId = requestAnimationFrame(() => {
rafId = null
const batch = Array.from(measureQueue)
measureQueue.clear()
batch.forEach((measure) => measure())
})
}
}
const cleanupFunctions = new WeakMap<
Ref<HTMLElement | null>,
{
stopWatcher?: WatchStopHandle
handleResize?: () => void
}
>()
interface SlotRegistrationOptions {
nodeId: string
slotIndex: number
isInput: boolean
element: Ref<HTMLElement | null>
transform?: TransformState
}
export function useDomSlotRegistration(options: SlotRegistrationOptions) {
const { nodeId, slotIndex, isInput, element: elRef, transform } = options
// Early return if no nodeId
if (!nodeId || nodeId === '') {
return {
remeasure: () => {}
}
}
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Track if this component is mounted
const componentToken = {}
// Cached offset from node position (avoids DOM reads during drag)
const cachedOffset = ref<LayoutPoint | null>(null)
const lastMeasuredBounds = ref<DOMRect | null>(null)
// Measure DOM and cache offset (expensive, minimize calls)
const measureAndCacheOffset = () => {
// Skip if component was unmounted
if (!mountedComponents.has(componentToken)) return
const el = elRef.value
if (!el || !transform?.screenToCanvas) return
const rect = el.getBoundingClientRect()
// Skip if bounds haven't changed significantly (within 0.5px)
if (lastMeasuredBounds.value) {
const prev = lastMeasuredBounds.value
if (
Math.abs(rect.left - prev.left) < 0.5 &&
Math.abs(rect.top - prev.top) < 0.5 &&
Math.abs(rect.width - prev.width) < 0.5 &&
Math.abs(rect.height - prev.height) < 0.5
) {
return // No significant change - skip update
}
}
lastMeasuredBounds.value = rect
// Center of the visual connector (dot) in screen coords
const centerScreen = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
}
const centerCanvas = transform.screenToCanvas(centerScreen)
// Cache offset from node position for fast updates during drag
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
cachedOffset.value = {
x: centerCanvas.x - nodeLayout.position.x,
y: centerCanvas.y - nodeLayout.position.y
}
}
updateSlotPosition(centerCanvas)
}
// Fast update using cached offset (no DOM read)
const updateFromCachedOffset = () => {
if (!cachedOffset.value) {
// No cached offset yet, need to measure
scheduleMeasurement(measureAndCacheOffset)
return
}
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (!nodeLayout) {
return
}
// Calculate absolute position from node position + cached offset
const centerCanvas = {
x: nodeLayout.position.x + cachedOffset.value.x,
y: nodeLayout.position.y + cachedOffset.value.y
}
updateSlotPosition(centerCanvas)
}
// Update slot position in layout store
const updateSlotPosition = (centerCanvas: LayoutPoint) => {
const size = LiteGraph.NODE_SLOT_HEIGHT
const half = size / 2
layoutStore.updateSlotLayout(slotKey, {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: centerCanvas.x, y: centerCanvas.y },
bounds: {
x: centerCanvas.x - half,
y: centerCanvas.y - half,
width: size,
height: size
}
})
}
onMounted(async () => {
// Mark component as mounted
mountedComponents.add(componentToken)
// Initial measure after mount
await nextTick()
measureAndCacheOffset()
// Subscribe to node position changes for fast cached updates
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
const stopWatcher = watch(
nodeRef,
(newLayout) => {
if (newLayout) {
// Node moved/resized - update using cached offset
updateFromCachedOffset()
}
},
{ immediate: false }
)
// Store cleanup functions without type assertions
const cleanup = cleanupFunctions.get(elRef) || {}
cleanup.stopWatcher = stopWatcher
// Window resize - remeasure as viewport changed
const handleResize = () => {
scheduleMeasurement(measureAndCacheOffset)
}
window.addEventListener('resize', handleResize, { passive: true })
cleanup.handleResize = handleResize
cleanupFunctions.set(elRef, cleanup)
})
onUnmounted(() => {
// Mark component as unmounted
mountedComponents.delete(componentToken)
// Clean up watchers and listeners
const cleanup = cleanupFunctions.get(elRef)
if (cleanup) {
if (cleanup.stopWatcher) cleanup.stopWatcher()
if (cleanup.handleResize) {
window.removeEventListener('resize', cleanup.handleResize)
}
cleanupFunctions.delete(elRef)
}
// Remove from layout store
layoutStore.deleteSlotLayout(slotKey)
// Remove from measurement queue if pending
measureQueue.delete(measureAndCacheOffset)
})
return {
// Expose for forced remeasure on structural changes
remeasure: () => scheduleMeasurement(measureAndCacheOffset)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
/**
* Composable for syncing LiteGraph with the Layout system
*
* Implements one-way sync from Layout Store to LiteGraph.
* The layout store is the single source of truth.
*/
import { onUnmounted } from 'vue'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
/**
* Composable for syncing LiteGraph with the Layout system
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
let unsubscribe: (() => void) | null = null
/**
* Start syncing from Layout system to LiteGraph
* This is one-way: Layout → LiteGraph only
*/
function startSync(canvas: any) {
if (!canvas?.graph) return
// Subscribe to layout changes
unsubscribe = layoutStore.onChange((change) => {
// Apply changes to LiteGraph regardless of source
// The layout store is the single source of truth
for (const nodeId of change.nodeIds) {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
if (!liteNode) continue
// Update position if changed
if (
liteNode.pos[0] !== layout.position.x ||
liteNode.pos[1] !== layout.position.y
) {
liteNode.pos[0] = layout.position.x
liteNode.pos[1] = layout.position.y
}
// Update size if changed
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
) {
liteNode.size[0] = layout.size.width
liteNode.size[1] = layout.size.height
}
}
// Trigger single redraw for all changes
canvas.setDirty(true, true)
})
}
/**
* Stop syncing
*/
function stopSync() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stopSync()
})
return {
startSync,
stopSync
}
}

View File

@@ -0,0 +1,365 @@
/**
* Composable for event-driven link layout synchronization
*
* Implements event-driven link layout updates decoupled from the render cycle.
* Updates link geometry only when it actually changes (node move/resize, link create/delete,
* reroute create/delete/move, collapse toggles).
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { ReadOnlyPoint } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { LayoutChange } from '@/renderer/core/layout/types'
const logger = log.getLogger('useLinkLayoutSync')
/**
* Composable for managing link layout synchronization
*/
export function useLinkLayoutSync() {
let canvas: LGraphCanvas | null = null
let graph: LGraph | null = null
let offscreenCtx: CanvasRenderingContext2D | null = null
let adapter: LitegraphLinkAdapter | null = null
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
/**
* Build link render context from canvas properties
*/
function buildLinkRenderContext(): LinkRenderContext {
if (!canvas) {
throw new Error('Canvas not initialized')
}
return {
// Canvas settings
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
// State
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
// Colors
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as any).link_type_colors || {},
// Pattern for disabled links
disabledPattern: canvas._pattern
}
}
/**
* Recompute a single link and all its segments
*
* Note: This logic mirrors LGraphCanvas#renderAllLinkSegments but:
* - Works with offscreen context for event-driven updates
* - No visibility checks (always computes full geometry)
* - No dragging state handling (pure geometry computation)
*/
function recomputeLinkById(linkId: number): void {
if (!graph || !adapter || !offscreenCtx || !canvas) return
const link = graph.links.get(linkId)
if (!link || link.id === -1) return // Skip floating/temp links
// Get source and target nodes
const sourceNode = graph.getNodeById(link.origin_id)
const targetNode = graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) return
// Get slots
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) return
// Get positions
const startPos = getSlotPosition(sourceNode, link.origin_slot, false)
const endPos = getSlotPosition(targetNode, link.target_slot, true)
// Get directions
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Get reroutes for this link
const reroutes = LLink.getReroutes(graph, link)
// Build render context
const context = buildLinkRenderContext()
if (reroutes.length > 0) {
// Render segmented link with reroutes
let segmentStartPos = startPos
let segmentStartDir = startDir
for (let i = 0; i < reroutes.length; i++) {
const reroute = reroutes[i]
// Calculate reroute angle
reroute.calculateAngle(Date.now(), graph, [
segmentStartPos[0],
segmentStartPos[1]
])
// Calculate control points
const distance = Math.sqrt(
(reroute.pos[0] - segmentStartPos[0]) ** 2 +
(reroute.pos[1] - segmentStartPos[1]) ** 2
)
const dist = Math.min(Reroute.maxSplineOffset, distance * 0.25)
// Special handling for floating input chain
const isFloatingInputChain = !sourceNode && targetNode
const startControl: ReadOnlyPoint = isFloatingInputChain
? [0, 0]
: [dist * reroute.cos, dist * reroute.sin]
// Render segment to this reroute
adapter.renderLinkDirect(
offscreenCtx,
segmentStartPos,
reroute.pos,
link,
true, // skip_border
0, // flow
null, // color
segmentStartDir,
LinkDirection.CENTER,
context,
{
startControl,
endControl: reroute.controlPoint,
reroute,
disabled: false
}
)
// Prepare for next segment
segmentStartPos = reroute.pos
segmentStartDir = LinkDirection.CENTER
}
// Render final segment from last reroute to target
const lastReroute = reroutes[reroutes.length - 1]
const finalDistance = Math.sqrt(
(endPos[0] - lastReroute.pos[0]) ** 2 +
(endPos[1] - lastReroute.pos[1]) ** 2
)
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
const finalStartControl: ReadOnlyPoint = [
finalDist * lastReroute.cos,
finalDist * lastReroute.sin
]
adapter.renderLinkDirect(
offscreenCtx,
lastReroute.pos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
LinkDirection.CENTER,
endDir,
context,
{
startControl: finalStartControl,
disabled: false
}
)
} else {
// No reroutes - render direct link
adapter.renderLinkDirect(
offscreenCtx,
startPos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
startDir,
endDir,
context,
{
disabled: false
}
)
}
}
/**
* Recompute all links connected to a node
*/
function recomputeLinksForNode(nodeId: number): void {
if (!graph) return
const node = graph.getNodeById(nodeId)
if (!node) return
const linkIds = new Set<number>()
// Collect output links
if (node.outputs) {
for (const output of node.outputs) {
if (output.links) {
for (const linkId of output.links) {
linkIds.add(linkId)
}
}
}
}
// Collect input links
if (node.inputs) {
for (const input of node.inputs) {
if (input.link !== null && input.link !== undefined) {
linkIds.add(input.link)
}
}
}
// Recompute each link
for (const linkId of linkIds) {
recomputeLinkById(linkId)
}
}
/**
* Recompute all links associated with a reroute
*/
function recomputeLinksForReroute(rerouteId: number): void {
if (!graph) return
const reroute = graph.reroutes.get(rerouteId)
if (!reroute) return
// Recompute all links that pass through this reroute
for (const linkId of reroute.linkIds) {
recomputeLinkById(linkId)
}
}
/**
* Start link layout sync with event-driven functionality
*/
function start(canvasInstance: LGraphCanvas): void {
canvas = canvasInstance
graph = canvas.graph
if (!graph) return
// Create offscreen canvas context
const offscreenCanvas = document.createElement('canvas')
offscreenCtx = offscreenCanvas.getContext('2d')
if (!offscreenCtx) {
logger.error('Failed to create offscreen canvas context')
return
}
// Create dedicated adapter with layout writes enabled
adapter = new LitegraphLinkAdapter(graph)
adapter.enableLayoutStoreWrites = true
// Initial computation for all existing links
for (const link of graph._links.values()) {
if (link.id !== -1) {
recomputeLinkById(link.id)
}
}
// Subscribe to layout store changes
unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => {
switch (change.operation.type) {
case 'moveNode':
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break
case 'deleteLink':
// No-op - store already cleaned by existing code
break
case 'createReroute':
case 'deleteReroute':
// Recompute all affected links
if ('linkIds' in change.operation) {
for (const linkId of change.operation.linkIds) {
recomputeLinkById(linkId)
}
}
break
case 'moveReroute':
recomputeLinksForReroute(change.operation.rerouteId)
break
}
})
// Hook collapse events
const origTrigger = graph.onTrigger
graph.onTrigger = (action: string, param: any) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
) {
const nodeId = parseInt(String(param.nodeId))
if (!isNaN(nodeId)) {
recomputeLinksForNode(nodeId)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
}
// Store cleanup function
restoreHandlers = () => {
if (graph) {
graph.onTrigger = origTrigger || undefined
}
}
}
/**
* Stop link layout sync and cleanup all resources
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
canvas = null
graph = null
offscreenCtx = null
adapter = null
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
return {
start,
stop
}
}

View File

@@ -0,0 +1,163 @@
/**
* Composable for managing slot layout registration
*
* Implements event-driven slot registration decoupled from the draw cycle.
* Registers slots once on initial load and keeps them updated when necessary.
*/
import { onUnmounted } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { type SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
/**
* Compute and register slot layouts for a node
* @param node LiteGraph node to process
*/
function computeAndRegisterSlots(node: LGraphNode): void {
const nodeId = String(node.id)
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
// Fallback to live node values if layout not ready
const nodeX = nodeLayout?.position.x ?? node.pos[0]
const nodeY = nodeLayout?.position.y ?? node.pos[1]
const nodeWidth = nodeLayout?.size.width ?? node.size[0]
const nodeHeight = nodeLayout?.size.height ?? node.size[1]
// Ensure concrete slots & arrange when needed for accurate positions
node._setConcreteSlots()
const collapsed = node.flags.collapsed ?? false
if (!collapsed) {
node.arrange()
}
const context: SlotPositionContext = {
nodeX,
nodeY,
nodeWidth,
nodeHeight,
collapsed,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
registerNodeSlots(nodeId, context)
}
/**
* Composable for managing slot layout registration
*/
export function useSlotLayoutSync() {
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
/**
* Start slot layout sync with full event-driven functionality
* @param canvas LiteGraph canvas instance
*/
function start(canvas: LGraphCanvas): void {
// When Vue nodes are enabled, slot DOM registers exact positions.
// Skip calculated registration to avoid conflicts.
if (LiteGraph.vueNodesMode) {
return
}
const graph = canvas?.graph
if (!graph) return
// Initial registration for all nodes in the current graph
for (const node of graph._nodes) {
computeAndRegisterSlots(node)
}
// Layout changes → recompute slots for changed nodes
unsubscribeLayoutChange = layoutStore.onChange((change) => {
for (const nodeId of change.nodeIds) {
const node = graph.getNodeById(parseInt(nodeId))
if (node) {
computeAndRegisterSlots(node)
}
}
})
// LiteGraph event hooks
const origNodeAdded = graph.onNodeAdded
const origNodeRemoved = graph.onNodeRemoved
const origTrigger = graph.onTrigger
const origAfterChange = graph.onAfterChange
graph.onNodeAdded = (node: LGraphNode) => {
computeAndRegisterSlots(node)
if (origNodeAdded) {
origNodeAdded.call(graph, node)
}
}
graph.onNodeRemoved = (node: LGraphNode) => {
layoutStore.deleteNodeSlotLayouts(String(node.id))
if (origNodeRemoved) {
origNodeRemoved.call(graph, node)
}
}
graph.onTrigger = (action: string, param: any) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
) {
const node = graph.getNodeById(parseInt(String(param.nodeId)))
if (node) {
computeAndRegisterSlots(node)
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
}
graph.onAfterChange = (graph: any, node?: any) => {
if (node && node.id) {
computeAndRegisterSlots(node)
}
if (origAfterChange) {
origAfterChange.call(graph, graph, node)
}
}
// Store cleanup function
restoreHandlers = () => {
graph.onNodeAdded = origNodeAdded || undefined
graph.onNodeRemoved = origNodeRemoved || undefined
graph.onTrigger = origTrigger || undefined
graph.onAfterChange = origAfterChange || undefined
}
}
/**
* Stop slot layout sync and cleanup all subscriptions
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
return {
start,
stop
}
}

View File

@@ -0,0 +1,322 @@
/**
* Layout System - Type Definitions
*
* This file contains all type definitions for the layout system
* that manages node positions, bounds, spatial data, and operations.
*/
import type { ComputedRef, Ref } from 'vue'
// Enum for layout source types
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
External = 'external'
}
// Basic geometric types
export interface Point {
x: number
y: number
}
export interface Size {
width: number
height: number
}
export interface Bounds {
x: number
y: number
width: number
height: number
}
export type NodeId = string
export type LinkId = number
export type RerouteId = number
// Layout data structures
export interface NodeLayout {
id: NodeId
position: Point
size: Size
zIndex: number
visible: boolean
// Computed bounds for hit testing
bounds: Bounds
}
export interface SlotLayout {
nodeId: NodeId
index: number
type: 'input' | 'output'
position: Point
bounds: Bounds
}
export interface LinkLayout {
id: LinkId
path: Path2D
bounds: Bounds
centerPos: Point
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
// Layout for individual link segments (for precise hit-testing)
export interface LinkSegmentLayout {
linkId: LinkId
rerouteId: RerouteId | null // null for final segment to target
path: Path2D
bounds: Bounds
centerPos: Point
}
export interface RerouteLayout {
id: RerouteId
position: Point
radius: number
bounds: Bounds
}
/**
* Meta-only base for all operations - contains common fields
*/
export interface OperationMeta {
/** Unique operation ID for deduplication */
id?: string
/** Timestamp for ordering operations */
timestamp: number
/** Actor who performed the operation (for CRDT) */
actor: string
/** Source system that initiated the operation */
source: LayoutSource
/** Operation type discriminator */
type: OperationType
}
/**
* Entity-specific base types for proper type discrimination
*/
export type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
export type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
export type RerouteOpBase = OperationMeta & {
entity: 'reroute'
rerouteId: RerouteId
}
/**
* Operation type discriminator for type narrowing
*/
export type OperationType =
| 'moveNode'
| 'resizeNode'
| 'setNodeZIndex'
| 'createNode'
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdate'
| 'createLink'
| 'deleteLink'
| 'createReroute'
| 'deleteReroute'
| 'moveReroute'
/**
* Move node operation
*/
export interface MoveNodeOperation extends NodeOpBase {
type: 'moveNode'
position: Point
previousPosition: Point
}
/**
* Resize node operation
*/
export interface ResizeNodeOperation extends NodeOpBase {
type: 'resizeNode'
size: { width: number; height: number }
previousSize: { width: number; height: number }
}
/**
* Set node z-index operation
*/
export interface SetNodeZIndexOperation extends NodeOpBase {
type: 'setNodeZIndex'
zIndex: number
previousZIndex: number
}
/**
* Create node operation
*/
export interface CreateNodeOperation extends NodeOpBase {
type: 'createNode'
layout: NodeLayout
}
/**
* Delete node operation
*/
export interface DeleteNodeOperation extends NodeOpBase {
type: 'deleteNode'
previousLayout: NodeLayout
}
/**
* Set node visibility operation
*/
export interface SetNodeVisibilityOperation extends NodeOpBase {
type: 'setNodeVisibility'
visible: boolean
previousVisible: boolean
}
/**
* Batch update operation for atomic multi-property changes
*/
export interface BatchUpdateOperation extends NodeOpBase {
type: 'batchUpdate'
updates: Partial<NodeLayout>
previousValues: Partial<NodeLayout>
}
/**
* Create link operation
*/
export interface CreateLinkOperation extends LinkOpBase {
type: 'createLink'
sourceNodeId: NodeId
sourceSlot: number
targetNodeId: NodeId
targetSlot: number
}
/**
* Delete link operation
*/
export interface DeleteLinkOperation extends LinkOpBase {
type: 'deleteLink'
}
/**
* Create reroute operation
*/
export interface CreateRerouteOperation extends RerouteOpBase {
type: 'createReroute'
position: Point
parentId?: RerouteId
linkIds: LinkId[]
}
/**
* Delete reroute operation
*/
export interface DeleteRerouteOperation extends RerouteOpBase {
type: 'deleteReroute'
}
/**
* Move reroute operation
*/
export interface MoveRerouteOperation extends RerouteOpBase {
type: 'moveReroute'
position: Point
previousPosition: Point
}
/**
* Union of all operation types
*/
export type LayoutOperation =
| MoveNodeOperation
| ResizeNodeOperation
| SetNodeZIndexOperation
| CreateNodeOperation
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
| DeleteRerouteOperation
| MoveRerouteOperation
export interface LayoutChange {
type: 'create' | 'update' | 'delete'
nodeIds: NodeId[]
timestamp: number
source: LayoutSource
operation: LayoutOperation
}
// Store interfaces
export interface LayoutStore {
// CustomRef accessors for shared write access
getNodeLayoutRef(nodeId: NodeId): Ref<NodeLayout | null>
getNodesInBounds(bounds: Bounds): ComputedRef<NodeId[]>
getAllNodes(): ComputedRef<ReadonlyMap<NodeId, NodeLayout>>
getVersion(): ComputedRef<number>
// Spatial queries (non-reactive)
queryNodeAtPoint(point: Point): NodeId | null
queryNodesInBounds(bounds: Bounds): NodeId[]
// Hit testing queries for links, slots, and reroutes
queryLinkAtPoint(point: Point, ctx?: CanvasRenderingContext2D): LinkId | null
queryLinkSegmentAtPoint(
point: Point,
ctx?: CanvasRenderingContext2D
): { linkId: LinkId; rerouteId: RerouteId | null } | null
querySlotAtPoint(point: Point): SlotLayout | null
queryRerouteAtPoint(point: Point): RerouteLayout | null
queryItemsInBounds(bounds: Bounds): {
nodes: NodeId[]
links: LinkId[]
slots: string[]
reroutes: RerouteId[]
}
// Update methods for link, slot, and reroute layouts
updateLinkLayout(linkId: LinkId, layout: LinkLayout): void
updateLinkSegmentLayout(
linkId: LinkId,
rerouteId: RerouteId | null,
layout: Omit<LinkSegmentLayout, 'linkId' | 'rerouteId'>
): void
updateSlotLayout(key: string, layout: SlotLayout): void
updateRerouteLayout(rerouteId: RerouteId, layout: RerouteLayout): void
// Delete methods for cleanup
deleteLinkLayout(linkId: LinkId): void
deleteLinkSegmentLayout(linkId: LinkId, rerouteId: RerouteId | null): void
deleteSlotLayout(key: string): void
deleteNodeSlotLayouts(nodeId: NodeId): void
deleteRerouteLayout(rerouteId: RerouteId): void
// Get layout data
getLinkLayout(linkId: LinkId): LinkLayout | null
getSlotLayout(key: string): SlotLayout | null
getRerouteLayout(rerouteId: RerouteId): RerouteLayout | null
// Direct mutation API (CRDT-ready)
applyOperation(operation: LayoutOperation): void
// Change subscription
onChange(callback: (change: LayoutChange) => void): () => void
// Initialization
initializeFromLiteGraph(
nodes: Array<{ id: string; pos: [number, number]; size: [number, number] }>
): void
// Source and actor management
setSource(source: LayoutSource): void
setActor(actor: string): void
getCurrentSource(): LayoutSource
getCurrentActor(): string
}

View File

@@ -0,0 +1,169 @@
/**
* Spatial Index Manager
*
* Manages spatial indexing for efficient node queries based on bounds.
* Uses QuadTree for fast spatial lookups with caching for performance.
*/
import {
PERFORMANCE_CONFIG,
QUADTREE_CONFIG
} from '@/renderer/core/layout/constants'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { QuadTree } from '@/utils/spatial/QuadTree'
/**
* Cache entry for spatial queries
*/
interface CacheEntry {
result: NodeId[]
timestamp: number
}
/**
* Spatial index manager using QuadTree
*/
export class SpatialIndexManager {
private quadTree: QuadTree<NodeId>
private queryCache: Map<string, CacheEntry>
private cacheSize = 0
constructor(bounds?: Bounds) {
this.quadTree = new QuadTree<NodeId>(
bounds ?? QUADTREE_CONFIG.DEFAULT_BOUNDS,
{
maxDepth: QUADTREE_CONFIG.MAX_DEPTH,
maxItemsPerNode: QUADTREE_CONFIG.MAX_ITEMS_PER_NODE
}
)
this.queryCache = new Map()
}
/**
* Insert a node into the spatial index
*/
insert(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.insert(nodeId, bounds, nodeId)
this.invalidateCache()
}
/**
* Update a node's bounds in the spatial index
*/
update(nodeId: NodeId, bounds: Bounds): void {
this.quadTree.update(nodeId, bounds)
this.invalidateCache()
}
/**
* Remove a node from the spatial index
*/
remove(nodeId: NodeId): void {
this.quadTree.remove(nodeId)
this.invalidateCache()
}
/**
* Query nodes within the given bounds
*/
query(bounds: Bounds): NodeId[] {
const cacheKey = this.getCacheKey(bounds)
const cached = this.queryCache.get(cacheKey)
// Check cache validity
if (cached) {
const age = Date.now() - cached.timestamp
if (age < PERFORMANCE_CONFIG.SPATIAL_CACHE_TTL) {
return cached.result
}
// Remove stale entry
this.queryCache.delete(cacheKey)
this.cacheSize--
}
// Perform query
const result = this.quadTree.query(bounds)
// Cache result
this.addToCache(cacheKey, result)
return result
}
/**
* Clear all nodes from the spatial index
*/
clear(): void {
this.quadTree.clear()
this.invalidateCache()
}
/**
* Get the current size of the index
*/
get size(): number {
return this.quadTree.size
}
/**
* Get debug information about the spatial index
*/
getDebugInfo() {
return {
quadTreeInfo: this.quadTree.getDebugInfo(),
cacheSize: this.cacheSize,
cacheEntries: this.queryCache.size
}
}
/**
* Generate cache key for bounds
*/
private getCacheKey(bounds: Bounds): string {
return `${bounds.x},${bounds.y},${bounds.width},${bounds.height}`
}
/**
* Add result to cache with LRU eviction
*/
private addToCache(key: string, result: NodeId[]): void {
// Evict oldest entries if cache is full
if (this.cacheSize >= PERFORMANCE_CONFIG.SPATIAL_CACHE_MAX_SIZE) {
const oldestKey = this.findOldestCacheEntry()
if (oldestKey) {
this.queryCache.delete(oldestKey)
this.cacheSize--
}
}
this.queryCache.set(key, {
result,
timestamp: Date.now()
})
this.cacheSize++
}
/**
* Find oldest cache entry for LRU eviction
*/
private findOldestCacheEntry(): string | null {
let oldestKey: string | null = null
let oldestTime = Infinity
for (const [key, entry] of this.queryCache) {
if (entry.timestamp < oldestTime) {
oldestTime = entry.timestamp
oldestKey = key
}
}
return oldestKey
}
/**
* Invalidate all cached queries
*/
private invalidateCache(): void {
this.queryCache.clear()
this.cacheSize = 0
}
}

View File

@@ -0,0 +1,107 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--input flex items-center cursor-crosshair group rounded-r-lg"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pr-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
class="-translate-x-1/2"
/>
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
</div>
</template>
<script setup lang="ts">
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import {
COMFY_VUE_NODE_DIMENSIONS,
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {
node?: LGraphNode
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<InputSlotProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<{ slotElRef: Ref<HTMLElement> }>()
const slotElRef = ref<HTMLElement | null>(null)
// Watch for connection dot ref changes and sync the element ref
watch(
connectionDotRef,
(newValue) => {
if (newValue?.slotElRef) {
slotElRef.value = newValue.slotElRef.value
}
},
{ immediate: true }
)
useDomSlotRegistration({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
isInput: true,
element: slotElRef,
transform: transformState
})
</script>

View File

@@ -0,0 +1,271 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Render Error') }}
</div>
<div
v-else
:data-node-id="nodeData.id"
:class="
cn(
'bg-white dark-theme:bg-[#15161A]',
'min-w-[445px]',
'lg-node absolute border border-solid rounded-2xl',
'outline outline-transparent outline-2 hover:outline-black dark-theme:hover:outline-white',
{
'border-blue-500 ring-2 ring-blue-300': selected,
'border-[#e1ded5] dark-theme:border-[#292A30]': !selected,
'animate-pulse': executing,
'opacity-50': nodeData.mode === 4,
'border-red-500 bg-red-50': error,
'will-change-transform': isDragging
},
lodCssClass
)
"
:style="[
{
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
pointerEvents: 'auto'
},
dragStyle
]"
@pointerdown="handlePointerDown"
@pointermove="handlePointerMove"
@pointerup="handlePointerUp"
>
<div class="flex items-center">
<template v-if="isCollapsed">
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, lodLevel, isCollapsed]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
:collapsed="isCollapsed"
@collapse="handleCollapse"
@update:title="handleTitleUpdate"
/>
</div>
<template v-if="!isMinimalLOD && !isCollapsed">
<div :class="cn(separatorClasses, 'mb-4')" />
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-col gap-4 pb-4"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-if="shouldRenderSlots"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
@slot-click="handleSlotClick"
/>
<div
v-if="shouldRenderSlots && shouldShowWidgets"
:class="separatorClasses"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="shouldShowWidgets"
v-memo="[nodeData.widgets?.length, lodLevel]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<div
v-if="(shouldRenderSlots || shouldShowWidgets) && shouldShowContent"
:class="separatorClasses"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="shouldShowContent"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
</div>
</template>
<!-- Progress bar for executing state -->
<div
v-if="executing && progress !== undefined"
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
:style="{ width: `${progress * 100}%` }"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
// Extended props for main node component
interface LGraphNodeProps {
nodeData: VueNodeData
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
selected?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
const props = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [event: PointerEvent, nodeData: VueNodeData]
'slot-click': [
event: PointerEvent,
nodeData: VueNodeData,
slotIndex: number,
isInput: boolean
]
'update:collapsed': [nodeId: string, collapsed: boolean]
'update:title': [nodeId: string, newTitle: string]
}>()
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => props.zoomLevel ?? 1)
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(zoomRef)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false // Prevent error propagation
})
// Use layout system for node position and dragging
const {
position: layoutPosition,
startDrag,
handleDrag: handleLayoutDrag,
endDrag
} = useNodeLayout(props.nodeData.id)
// Drag state for styling
const isDragging = ref(false)
const dragStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab'
}))
// Track collapsed state
const isCollapsed = ref(props.nodeData.flags?.collapsed ?? false)
// Watch for external changes to the collapsed state
watch(
() => props.nodeData.flags?.collapsed,
(newCollapsed) => {
if (newCollapsed !== undefined && newCollapsed !== isCollapsed.value) {
isCollapsed.value = newCollapsed
}
}
)
// Check if node has custom content
const hasCustomContent = computed(() => {
// Currently all content is handled through widgets
// This remains false but provides extensibility point
return false
})
// Computed classes and conditions for better reusability
const separatorClasses = 'bg-[#e1ded5] dark-theme:bg-[#292A30] h-[1px] mx-0'
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && props.nodeData.widgets?.length
)
const shouldShowContent = computed(
() => shouldRenderContent.value && hasCustomContent.value
)
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!props.nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
// Start drag using layout system
isDragging.value = true
startDrag(event)
// Emit node-click for selection handling in GraphCanvas
emit('node-click', event, props.nodeData)
}
const handlePointerMove = (event: PointerEvent) => {
if (isDragging.value) {
void handleLayoutDrag(event)
}
}
const handlePointerUp = (event: PointerEvent) => {
if (isDragging.value) {
isDragging.value = false
void endDrag(event)
}
}
const handleCollapse = () => {
isCollapsed.value = !isCollapsed.value
// Emit event so parent can sync with LiteGraph if needed
emit('update:collapsed', props.nodeData.id, isCollapsed.value)
}
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
if (!props.nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handleSlotClick')
return
}
emit('slot-click', event, props.nodeData, slotIndex, isInput)
}
const handleTitleUpdate = (newTitle: string) => {
emit('update:title', props.nodeData.id, newTitle)
}
</script>

View File

@@ -0,0 +1,40 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Content Error') }}
</div>
<div v-else class="lg-node-content">
<!-- Default slot for custom content -->
<slot>
<!-- This component serves as a placeholder for future extensibility -->
<!-- Currently all node content is rendered through the widget system -->
</slot>
</div>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
}
defineProps<NodeContentProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,115 @@
<template>
<div v-if="renderError" class="node-error p-4 text-red-500 text-sm">
{{ $t('Node Header Error') }}
</div>
<div
v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move"
:data-testid="`node-header-${nodeInfo?.id || ''}`"
@dblclick="handleDoubleClick"
>
<!-- Collapse/Expand Button -->
<button
v-show="!readonly"
class="bg-transparent border-transparent flex items-center"
data-testid="node-collapse-button"
@click.stop="handleCollapse"
@dblclick.stop
>
<i
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
class="text-xs leading-none relative top-[1px] text-[#888682] dark-theme:text-[#5B5E7D]"
></i>
</button>
<!-- Node Title -->
<div class="text-sm font-bold truncate flex-1" data-testid="node-title">
<EditableText
:model-value="displayTitle"
:is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit"
@cancel="handleTitleCancel"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref, watch } from 'vue'
import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
interface NodeHeaderProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
collapsed?: boolean
}
const props = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'update:title': [newTitle: string]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Editing state
const isEditing = ref(false)
const nodeInfo = computed(() => props.nodeData || props.node)
// Local state for title to provide immediate feedback
const displayTitle = ref(nodeInfo.value?.title || 'Untitled')
// Watch for external changes to the node title
watch(
() => nodeInfo.value?.title,
(newTitle) => {
if (newTitle && newTitle !== displayTitle.value) {
displayTitle.value = newTitle
}
}
)
// Event handlers
const handleCollapse = () => {
emit('collapse')
}
const handleDoubleClick = () => {
if (!props.readonly) {
isEditing.value = true
}
}
const handleTitleEdit = (newTitle: string) => {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
// Emit for litegraph sync
emit('update:title', trimmedTitle)
}
}
const handleTitleCancel = () => {
isEditing.value = false
// Reset displayTitle to the current node title
displayTitle.value = nodeInfo.value?.title || 'Untitled'
}
</script>

View File

@@ -0,0 +1,113 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Slots Error') }}
</div>
<div v-else class="lg-node-slots flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col gap-1">
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
:slot-data="input"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
/>
</div>
<div v-if="filteredOutputs.length" class="flex flex-col gap-1 ml-auto">
<OutputSlot
v-for="(output, index) in filteredOutputs"
:key="`output-${index}`"
:slot-data="output"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="index"
:readonly="readonly"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { isSlotObject } from '@/utils/typeGuardUtil'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeSlotsProps>()
const nodeInfo = computed(() => props.nodeData || props.node || null)
// Filter out input slots that have corresponding widgets
const filteredInputs = computed(() => {
if (!nodeInfo.value?.inputs) return []
return nodeInfo.value.inputs
.filter((input) => {
// Check if this slot has a widget property (indicating it has a corresponding widget)
if (isSlotObject(input) && 'widget' in input && input.widget) {
// This slot has a widget, so we should not display it separately
return false
}
return true
})
.map((input) =>
isSlotObject(input)
? input
: ({
name: typeof input === 'string' ? input : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Outputs don't have widgets, so we don't need to filter them
const filteredOutputs = computed(() => {
const outputs = nodeInfo.value?.outputs || []
return outputs.map((output) =>
isSlotObject(output)
? output
: ({
name: typeof output === 'string' ? output : '',
type: 'any',
boundingRect: [0, 0, 0, 0] as [number, number, number, number]
} as INodeSlot)
)
})
// Get the actual index of an input slot in the node's inputs array
// (accounting for filtered widget slots)
const getActualInputIndex = (
input: INodeSlot,
filteredIndex: number
): number => {
if (!nodeInfo.value?.inputs) return filteredIndex
// Find the actual index in the unfiltered inputs array
const actualIndex = nodeInfo.value.inputs.findIndex((i) => i === input)
return actualIndex !== -1 ? actualIndex : filteredIndex
}
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
{{ $t('Node Widgets Error') }}
</div>
<div v-else class="lg-node-widgets flex flex-col gap-2 pr-4">
<div
v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`"
class="lg-widget-container relative flex items-center group"
>
<!-- Widget Input Slot Dot -->
<div
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
>
<InputSlot
:slot-data="{
name: widget.name,
type: widget.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeInfo?.id != null ? String(nodeInfo.id) : ''"
:index="getWidgetInputIndex(widget)"
:readonly="readonly"
:dot-only="true"
/>
</div>
<!-- Widget Component -->
<component
:is="widget.vueComponent"
:widget="widget.simplified"
:model-value="widget.value"
:readonly="readonly"
class="flex-1"
@update:model-value="widget.updateHandler"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import {
getComponent,
isEssential,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
node?: LGraphNode
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeWidgetsProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
const nodeInfo = computed(() => props.nodeData || props.node)
interface ProcessedWidget {
name: string
type: string
vueComponent: any
simplified: SimplifiedWidget
value: WidgetValue
updateHandler: (value: unknown) => void
}
const processedWidgets = computed((): ProcessedWidget[] => {
const info = nodeInfo.value
if (!info?.widgets) return []
const widgets = info.widgets as SafeWidgetData[]
const lodLevel = props.lodLevel
const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
return []
}
for (const widget of widgets) {
if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue
if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText
const simplified: SimplifiedWidget = {
name: widget.name,
type: widget.type,
value: widget.value,
options: widget.options,
callback: widget.callback
}
const updateHandler = (value: unknown) => {
if (widget.callback) {
widget.callback(value)
}
}
result.push({
name: widget.name,
type: widget.type,
vueComponent,
simplified,
value: widget.value,
updateHandler
})
}
return result
})
// TODO: Refactor to avoid O(n) lookup - consider storing input index on widget creation
// or restructuring data model to unify widgets and inputs
// Map a widget to its corresponding input slot index
const getWidgetInputIndex = (widget: ProcessedWidget): number => {
const inputs = nodeInfo.value?.inputs
if (!inputs) return 0
const idx = inputs.findIndex((input: any) => {
if (!input || typeof input !== 'object') return false
if (!('name' in input && 'type' in input)) return false
return 'widget' in input && input.widget?.name === widget.name
})
return idx >= 0 ? idx : 0
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div
v-else
class="lg-slot lg-slot--output flex items-center cursor-crosshair justify-end group rounded-l-lg"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pl-6 hover:bg-black/5 hover:dark:bg-white/5': !dotOnly,
'justify-center': dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
>
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-[#9FA2BD] text-[#888682]"
>
{{ slotData.name || `Output ${index}` }}
</span>
<!-- Connection Dot -->
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
class="translate-x-1/2"
/>
</div>
</template>
<script setup lang="ts">
import { type Ref, computed, inject, onErrorCaptured, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import type { INodeSlot, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
// DOM-based slot registration for arbitrary positioning
import {
type TransformState,
useDomSlotRegistration
} from '@/renderer/core/layout/slots/useDomSlotRegistration'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps {
node?: LGraphNode
nodeId?: string
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<OutputSlotProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Get slot height from litegraph constants
const slotHeight = COMFY_VUE_NODE_DIMENSIONS.components.SLOT_HEIGHT
const transformState = inject<TransformState | undefined>(
'transformState',
undefined
)
const connectionDotRef = ref<{ slotElRef: Ref<HTMLElement> }>()
const slotElRef = ref<HTMLElement | null>(null)
// Watch for connection dot ref changes and sync the element ref
watch(
connectionDotRef,
(newValue) => {
if (newValue?.slotElRef) {
slotElRef.value = newValue.slotElRef.value
}
},
{ immediate: true }
)
useDomSlotRegistration({
nodeId: props.nodeId ?? '',
slotIndex: props.index,
isInput: false,
element: slotElRef,
transform: transformState
})
</script>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useTemplateRef } from 'vue'
import { type ClassValue, cn } from '@/utils/tailwindUtil'
const props = defineProps<{
color?: string
multi?: boolean
class?: ClassValue
}>()
const slotElRef = useTemplateRef('slot-el')
defineExpose({
slotElRef
})
</script>
<template>
<div
:class="
cn('size-6 flex items-center justify-center group/slot', props.class)
"
>
<div
ref="slot-el"
:style="{ backgroundColor: color }"
:class="
cn(
'bg-[#5B5E7D] rounded-full',
'transition-all duration-150',
'cursor-crosshair',
'border border-solid border-black/5 dark-theme:border-white/10',
'group-hover/slot:border-black/20 dark-theme:group-hover/slot:border-white/50 group-hover/slot:scale-125',
multi ? 'w-3 h-6' : 'size-3'
)
"
/>
</div>
</template>

View File

@@ -0,0 +1,295 @@
# Level of Detail (LOD) Implementation Guide for Widgets
## What is Level of Detail (LOD)?
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants.
For ComfyUI nodes, this means:
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
## Why LOD Matters
Without LOD optimization:
- 1000+ nodes with full detail = browser lag and poor performance
- Text that's too small to read still gets rendered (wasted work)
- Visual effects that are invisible at distance still consume GPU
With LOD optimization:
- Smooth performance even with large node graphs
- Battery life improvement on laptops
- Better user experience across different zoom levels
## How to Implement LOD in Your Widget
### Step 1: Get the LOD Context
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
```vue
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{
widget: any
zoomLevel: number
// ... other props
}>()
// Get LOD information
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
</script>
```
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
### Step 2: Choose What to Show at Different Zoom Levels
#### Understanding the LOD Score
- `lodScore` is a number from 0 to 1
- 0 = completely zoomed out (show minimal detail)
- 1 = fully zoomed in (show everything)
- 0.5 = medium zoom (show some details)
#### Understanding LOD Levels
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
- `'full'` = zoom level 0.8 or above (zoomed in close)
### Step 3: Implement Your Widget's LOD Strategy
Here's a complete example of a slider widget with LOD:
```vue
<template>
<div class="number-widget">
<!-- The main control always shows -->
<input
v-model="value"
type="range"
:min="widget.min"
:max="widget.max"
class="widget-slider"
/>
<!-- Show label only when zoomed in enough to read it -->
<label
v-if="showLabel"
class="widget-label"
>
{{ widget.name }}
</label>
<!-- Show precise value only when fully zoomed in -->
<span
v-if="showValue"
class="widget-value"
>
{{ formattedValue }}
</span>
<!-- Show description only at full detail -->
<div
v-if="showDescription && widget.description"
class="widget-description"
>
{{ widget.description }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{
widget: any
zoomLevel: number
}>()
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
// Define when to show each element
const showLabel = computed(() => {
// Show label when user can actually read it
return lodScore.value > 0.4 // Roughly 12px+ text size
})
const showValue = computed(() => {
// Show precise value only when zoomed in close
return lodScore.value > 0.7 // User is focused on this specific widget
})
const showDescription = computed(() => {
// Description only at full detail
return lodLevel.value === 'full' // Maximum zoom level
})
// You can also use LOD for styling
const widgetClasses = computed(() => {
const classes = ['number-widget']
if (lodLevel.value === 'minimal') {
classes.push('widget--minimal')
}
return classes
})
</script>
<style scoped>
/* Apply different styles based on LOD */
.widget--minimal {
/* Simplified appearance when zoomed out */
.widget-slider {
height: 4px; /* Thinner slider */
opacity: 0.9;
}
}
/* Normal styling */
.widget-slider {
height: 8px;
transition: height 0.2s ease;
}
.widget-label {
font-size: 0.8rem;
color: var(--text-secondary);
}
.widget-value {
font-family: monospace;
font-size: 0.7rem;
color: var(--text-accent);
}
.widget-description {
font-size: 0.6rem;
color: var(--text-muted);
margin-top: 4px;
}
</style>
```
## Common LOD Patterns
### Pattern 1: Essential vs. Nice-to-Have
```typescript
// Always show the main functionality
const showMainControl = computed(() => true)
// Granular control with lodScore
const showLabels = computed(() => lodScore.value > 0.4)
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
// Simple control with lodLevel
const showExtras = computed(() => lodLevel.value === 'full')
```
### Pattern 2: Smooth Opacity Transitions
```typescript
// Gradually fade elements based on zoom
const labelOpacity = computed(() => {
// Fade in from zoom 0.3 to 0.6
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
})
```
### Pattern 3: Progressive Detail
```typescript
const detailLevel = computed(() => {
if (lodScore.value < 0.3) return 'none'
if (lodScore.value < 0.6) return 'basic'
if (lodScore.value < 0.8) return 'standard'
return 'full'
})
```
## LOD Guidelines by Widget Type
### Text Input Widgets
- **Always show**: The input field itself
- **Medium zoom**: Show label
- **High zoom**: Show placeholder text, validation messages
- **Full zoom**: Show character count, format hints
### Button Widgets
- **Always show**: The button
- **Medium zoom**: Show button text
- **High zoom**: Show button description
- **Full zoom**: Show keyboard shortcuts, tooltips
### Selection Widgets (Dropdown, Radio)
- **Always show**: The current selection
- **Medium zoom**: Show option labels
- **High zoom**: Show all options when expanded
- **Full zoom**: Show option descriptions, icons
### Complex Widgets (Color Picker, File Browser)
- **Always show**: Simplified representation (color swatch, filename)
- **Medium zoom**: Show basic controls
- **High zoom**: Show full interface
- **Full zoom**: Show advanced options, previews
## Design Collaboration Guidelines
### For Designers
When designing widgets, consider creating variants for different zoom levels:
1. **Minimal Design** (far away view)
- Essential elements only
- Higher contrast for visibility
- Simplified shapes and fewer details
2. **Standard Design** (normal view)
- Balanced detail and simplicity
- Clear labels and readable text
- Good for most use cases
3. **Full Detail Design** (close-up view)
- All labels, descriptions, and help text
- Rich visual effects and polish
- Maximum information density
### Design Handoff Checklist
- [ ] Specify which elements are essential vs. nice-to-have
- [ ] Define minimum readable sizes for text elements
- [ ] Provide simplified versions for distant viewing
- [ ] Consider color contrast at different opacity levels
- [ ] Test designs at multiple zoom levels
## Testing Your LOD Implementation
### Manual Testing
1. Create a workflow with your widget
2. Zoom out until nodes are very small
3. Verify essential functionality still works
4. Zoom in gradually and check that details appear smoothly
5. Test performance with 50+ nodes containing your widget
### Performance Considerations
- Avoid complex calculations in LOD computed properties
- Use `v-if` instead of `v-show` for elements that won't render
- Consider using `v-memo` for expensive widget content
- Test on lower-end devices
### Common Mistakes
**Don't**: Hide the main widget functionality at any zoom level
**Don't**: Use complex animations that trigger at every zoom change
**Don't**: Make LOD thresholds too sensitive (causes flickering)
**Don't**: Forget to test with real content and edge cases
**Do**: Keep essential functionality always visible
**Do**: Use smooth transitions between LOD levels
**Do**: Test with varying content lengths and types
**Do**: Consider accessibility at all zoom levels
## Getting Help
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
- Ask in the ComfyUI frontend Discord for LOD implementation questions
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
- Profile performance impact using browser dev tools

View File

@@ -0,0 +1,168 @@
/**
* Composable for individual Vue node components
*
* Uses customRef for shared write access with Canvas renderer.
* Provides dragging functionality and reactive layout state.
*/
import { computed, inject } from 'vue'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource, type Point } from '@/renderer/core/layout/types'
/**
* Composable for individual Vue node components
* Uses customRef for shared write access with Canvas renderer
*/
export function useNodeLayout(nodeId: string) {
const store = layoutStore
const mutations = useLayoutMutations()
// Get transform utilities from TransformPane if available
const transformState = inject('transformState') as
| {
canvasToScreen: (point: Point) => Point
screenToCanvas: (point: Point) => Point
}
| undefined
// Get the customRef for this node (shared write access)
const layoutRef = store.getNodeLayoutRef(nodeId)
// Computed properties for easy access
const position = computed(() => {
const layout = layoutRef.value
const pos = layout?.position ?? { x: 0, y: 0 }
return pos
})
const size = computed(
() => layoutRef.value?.size ?? { width: 200, height: 100 }
)
const bounds = computed(
() =>
layoutRef.value?.bounds ?? {
x: position.value.x,
y: position.value.y,
width: size.value.width,
height: size.value.height
}
)
const isVisible = computed(() => layoutRef.value?.visible ?? true)
const zIndex = computed(() => layoutRef.value?.zIndex ?? 0)
// Drag state
let isDragging = false
let dragStartPos: Point | null = null
let dragStartMouse: Point | null = null
/**
* Start dragging the node
*/
function startDrag(event: PointerEvent) {
if (!layoutRef.value) return
isDragging = true
dragStartPos = { ...position.value }
dragStartMouse = { x: event.clientX, y: event.clientY }
// Set mutation source
mutations.setSource(LayoutSource.Vue)
// Capture pointer
const target = event.target as HTMLElement
target.setPointerCapture(event.pointerId)
}
/**
* Handle drag movement
*/
const handleDrag = (event: PointerEvent) => {
if (!isDragging || !dragStartPos || !dragStartMouse || !transformState) {
return
}
// Calculate mouse delta in screen coordinates
const mouseDelta = {
x: event.clientX - dragStartMouse.x,
y: event.clientY - dragStartMouse.y
}
// Convert to canvas coordinates
const canvasOrigin = transformState.screenToCanvas({ x: 0, y: 0 })
const canvasWithDelta = transformState.screenToCanvas(mouseDelta)
const canvasDelta = {
x: canvasWithDelta.x - canvasOrigin.x,
y: canvasWithDelta.y - canvasOrigin.y
}
// Calculate new position
const newPosition = {
x: dragStartPos.x + canvasDelta.x,
y: dragStartPos.y + canvasDelta.y
}
// Apply mutation through the layout system
mutations.moveNode(nodeId, newPosition)
}
/**
* End dragging
*/
function endDrag(event: PointerEvent) {
if (!isDragging) return
isDragging = false
dragStartPos = null
dragStartMouse = null
// Release pointer
const target = event.target as HTMLElement
target.releasePointerCapture(event.pointerId)
}
/**
* Update node position directly (without drag)
*/
function moveTo(position: Point) {
mutations.setSource(LayoutSource.Vue)
mutations.moveNode(nodeId, position)
}
/**
* Update node size
*/
function resize(newSize: { width: number; height: number }) {
mutations.setSource(LayoutSource.Vue)
mutations.resizeNode(nodeId, newSize)
}
return {
// Reactive state (via customRef)
layoutRef,
position,
size,
bounds,
isVisible,
zIndex,
// Mutations
moveTo,
resize,
// Drag handlers
startDrag,
handleDrag,
endDrag,
// Computed styles for Vue templates
nodeStyle: computed(() => ({
position: 'absolute' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
zIndex: zIndex.value,
cursor: isDragging ? 'grabbing' : 'grab'
}))
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
<!-- Needs custom color picker for alpha support -->
<template>
<WidgetLayoutField :widget="widget">
<label
:class="
cn(WidgetInputBaseClass, 'flex items-center gap-2 w-full px-4 py-2')
"
>
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-8 h-4 !rounded-full overflow-hidden border-none"
:pt="{
preview: '!w-full !h-full !border-none'
}"
@update:model-value="onChange"
/>
<span class="text-xs">#{{ localValue }}</span>
</label>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: '#000000',
emit
})
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
<template>
<WidgetLayoutField :widget="widget">
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
display="chip"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<any[]>
modelValue: any[]
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: any[]]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: [],
emit
})
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() => {
const filtered = filterWidgetProps(
props.widget.options,
MULTISELECT_EXCLUDED_PROPS
)
// Ensure options array is available for MultiSelect
const values = props.widget.options?.values
if (values && Array.isArray(values)) {
filtered.options = values
}
return filtered
})
</script>

View File

@@ -0,0 +1,65 @@
<template>
<WidgetLayoutField :widget>
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@/utils/tailwindUtil'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
import { WidgetInputBaseClass } from './layout'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string | number | undefined>
modelValue: string | number | undefined
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string | number | undefined]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useWidgetValue({
widget: props.widget,
modelValue: props.modelValue,
defaultValue: props.widget.options?.values?.[0] || '',
emit
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -0,0 +1,36 @@
<template>
<WidgetLayoutField :widget="widget">
<FormSelectButton
v-model="localValue"
:options="widget.options?.values || []"
:disabled="readonly"
class="w-full"
@update:model-value="onChange"
/>
</WidgetLayoutField>
</template>
<script setup lang="ts">
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormSelectButton from './form/FormSelectButton.vue'
import WidgetLayoutField from './layout/WidgetLayoutField.vue'
const props = defineProps<{
widget: SimplifiedWidget<string>
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useStringWidgetValue(
props.widget,
props.modelValue,
emit
)
</script>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,108 @@
<template>
<div
:class="
cn(
WidgetInputBaseClass,
'p-1 inline-flex justify-center items-center gap-1'
)
"
>
<button
v-for="(option, index) in options"
:key="getOptionValue(option, index)"
:class="
cn(
'flex-1 h-6 px-5 py-[5px] rounded flex justify-center items-center gap-1 transition-all duration-150 ease-in-out',
'bg-transparent border-none',
'text-center text-xs font-normal',
{
'bg-white': isSelected(option) && !disabled,
'hover:bg-zinc-200/50': !isSelected(option) && !disabled,
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled
},
{
'text-neutral-900': isSelected(option) && !disabled,
'text-zinc-500': !isSelected(option) || disabled
}
)
"
:disabled="disabled"
@click="handleSelect(option)"
>
{{ getOptionLabel(option) }}
</button>
</div>
</template>
<script
setup
lang="ts"
generic="T extends string | number | { label: string; value: any }"
>
import { cn } from '@/utils/tailwindUtil'
import { WidgetInputBaseClass } from '../layout'
interface Props {
modelValue: string | null | undefined
options: T[] // Now using generic type instead of any[]
optionLabel?: string // PrimeVue compatible prop
optionValue?: string // PrimeVue compatible prop
disabled?: boolean
}
interface Emits {
'update:modelValue': [value: string]
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const emit = defineEmits<Emits>()
// handle both string/number arrays and object arrays with PrimeVue compatibility
const getOptionValue = (option: T, index: number): string => {
if (typeof option === 'object' && option !== null) {
// Use PrimeVue optionValue prop if provided, otherwise fallback to common fields
const valueField = props.optionValue ?? 'value'
const value =
(option as any)[valueField] ??
(option as any).value ??
(option as any).name ??
(option as any).label ??
index
return String(value)
}
return String(option)
}
// for display with PrimeVue compatibility
const getOptionLabel = (option: T): string => {
if (typeof option === 'object' && option !== null) {
// Use PrimeVue optionLabel prop if provided, otherwise fallback to common fields
const labelField = props.optionLabel ?? 'label'
return (
(option as any)[labelField] ??
(option as any).label ??
(option as any).name ??
(option as any).value ??
String(option)
)
}
return String(option)
}
const isSelected = (option: T): boolean => {
const optionValue = getOptionValue(option, props.options.indexOf(option))
return optionValue === String(props.modelValue ?? '')
}
const handleSelect = (option: T) => {
if (props.disabled) return
const optionValue = getOptionValue(option, props.options.indexOf(option))
emit('update:modelValue', optionValue)
}
</script>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { COMFY_VUE_NODE_DIMENSIONS } from '@/lib/litegraph/src/litegraph'
import { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
}>()
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
</script>
<template>
<div
class="flex items-center justify-between gap-2"
:style="{ height: widgetHeight + 'px' }"
>
<p
v-if="widget.name"
class="text-sm text-[#888682] dark-theme:text-[#9FA2BD] font-normal flex-1 truncate w-20"
>
{{ widget.name }}
</p>
<div class="w-75">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,14 @@
export const WidgetInputBaseClass = [
// Background
'bg-zinc-500/10',
// Outline
'border-none',
'outline',
'outline-1',
'outline-offset-[-1px]',
'outline-zinc-300/10',
// Rounded
'!rounded-lg',
// Hover
'hover:outline-blue-500/80'
].join(' ')

View File

@@ -0,0 +1,33 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isBooleanInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
export const useBooleanWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isBooleanInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? false
const options = {
on: inputSpec.label_on,
off: inputSpec.label_off
}
return node.addWidget(
'toggle',
inputSpec.name,
defaultVal,
() => {},
options
)
}
return widgetConstructor
}

View File

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

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type ChatHistoryCustomProps = Omit<
InstanceType<typeof ChatHistoryWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useChatHistoryWidget = (
options: {
props?: ChatHistoryCustomProps
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
ChatHistoryCustomProps
>({
node,
name: inputSpec.name,
component: ChatHistoryWidget,
props: options.props,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => 400 + PADDING
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

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

View File

@@ -0,0 +1,111 @@
import { ref } from 'vue'
import MultiSelectWidget from '@/components/graph/widgets/MultiSelectWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
ComboInputSpec,
type InputSpec,
isComboInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type BaseDOMWidget,
ComponentWidgetImpl,
addWidget
} from '@/scripts/domWidget'
import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const widgetValue = ref<string[]>([])
const widget = new ComponentWidgetImpl({
node,
name: inputSpec.name,
component: MultiSelectWidget,
inputSpec,
options: {
getValue: () => widgetValue.value,
setValue: (value: string[]) => {
widgetValue.value = value
}
}
})
addWidget(node, widget as BaseDOMWidget<object | string>)
// TODO: Add remote support to multi-select widget
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3003
return widget
}
const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
}
) as IComboWidget
if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
node,
widget
})
if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
// Assertion: Proxy handler passthrough
return prop !== 'values'
? target[prop as keyof typeof target]
: remoteWidget.getValue()
}
})
}
if (inputSpec.control_after_generate) {
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
return inputSpec.multi_select
? addMultiSelectWidget(node, inputSpec)
: addComboWidget(node, inputSpec)
}
return widgetConstructor
}

View File

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

View File

@@ -0,0 +1,81 @@
import { clamp } from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import {
type InputSpec,
isFloatInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onFloatValueChange(this: INumericWidget, v: number) {
const round = this.options.round
if (round) {
const precision =
this.options.precision ?? Math.max(0, -Math.floor(Math.log10(round)))
const rounded = Math.round(v / round) * round
this.value = clamp(
Number(rounded.toFixed(precision)),
this.options.min ?? -Infinity,
this.options.max ?? Infinity
)
} else {
this.value = v
}
}
export const _for_testing = {
onFloatValueChange
}
export const useFloatWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isFloatInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 0.5
const precision =
settingStore.get('Comfy.FloatRoundingPrecision') ||
Math.max(0, -Math.floor(Math.log10(step)))
const enableRounding = !settingStore.get('Comfy.DisableFloatRounding')
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
return node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onFloatValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
round:
enableRounding && precision && !inputSpec.round
? Math.pow(10, -precision)
: (inputSpec.round as number),
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10.0,
step2: step,
precision
}
)
}
return widgetConstructor
}

View File

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

View File

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

View File

@@ -0,0 +1,317 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
import { useSettingStore } from '@/stores/settingStore'
import { is_all_same_aspect_ratio } from '@/utils/imageUtil'
const renderPreview = (
ctx: CanvasRenderingContext2D,
node: LGraphNode,
shiftY: number
) => {
const canvas = useCanvasStore().getCanvas()
const mouse = canvas.graph_mouse
if (!canvas.pointer_is_down && node.pointerDown) {
if (
mouse[0] === node.pointerDown.pos[0] &&
mouse[1] === node.pointerDown.pos[1]
) {
node.imageIndex = node.pointerDown.index
}
node.pointerDown = null
}
const imgs = node.imgs ?? []
let { imageIndex } = node
const numImages = imgs.length
if (numImages === 1 && !imageIndex) {
// This skips the thumbnail render section below
node.imageIndex = imageIndex = 0
}
const settingStore = useSettingStore()
const allowImageSizeDraw = settingStore.get('Comfy.Node.AllowImageSizeDraw')
const IMAGE_TEXT_SIZE_TEXT_HEIGHT = allowImageSizeDraw ? 15 : 0
const dw = node.size[0]
const dh = node.size[1] - shiftY - IMAGE_TEXT_SIZE_TEXT_HEIGHT
if (imageIndex == null) {
// No image selected; draw thumbnails of all
let cellWidth: number
let cellHeight: number
let shiftX: number
let cell_padding: number
let cols: number
const compact_mode = is_all_same_aspect_ratio(imgs)
if (!compact_mode) {
// use rectangle cell style and border line
cell_padding = 2
// Prevent infinite canvas2d scale-up
const largestDimension = imgs.reduce(
(acc, current) =>
Math.max(acc, current.naturalWidth, current.naturalHeight),
0
)
const fakeImgs = []
fakeImgs.length = imgs.length
fakeImgs[0] = {
naturalWidth: largestDimension,
naturalHeight: largestDimension
}
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
fakeImgs,
dw,
dh
))
} else {
cell_padding = 0
;({ cellWidth, cellHeight, cols, shiftX } = calculateImageGrid(
imgs,
dw,
dh
))
}
let anyHovered = false
node.imageRects = []
for (let i = 0; i < numImages; i++) {
const img = imgs[i]
const row = Math.floor(i / cols)
const col = i % cols
const x = col * cellWidth + shiftX
const y = row * cellHeight + shiftY
if (!anyHovered) {
anyHovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
cellWidth,
cellHeight
)
if (anyHovered) {
node.overIndex = i
let value = 110
if (canvas.pointer_is_down) {
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
value = 125
}
ctx.filter = `contrast(${value}%) brightness(${value}%)`
canvas.canvas.style.cursor = 'pointer'
}
}
node.imageRects.push([x, y, cellWidth, cellHeight])
const wratio = cellWidth / img.width
const hratio = cellHeight / img.height
const ratio = Math.min(wratio, hratio)
const imgHeight = ratio * img.height
const imgY = row * cellHeight + shiftY + (cellHeight - imgHeight) / 2
const imgWidth = ratio * img.width
const imgX = col * cellWidth + shiftX + (cellWidth - imgWidth) / 2
ctx.drawImage(
img,
imgX + cell_padding,
imgY + cell_padding,
imgWidth - cell_padding * 2,
imgHeight - cell_padding * 2
)
if (!compact_mode) {
// rectangle cell and border line style
ctx.strokeStyle = '#8F8F8F'
ctx.lineWidth = 1
ctx.strokeRect(
x + cell_padding,
y + cell_padding,
cellWidth - cell_padding * 2,
cellHeight - cell_padding * 2
)
}
ctx.filter = 'none'
}
if (!anyHovered) {
node.pointerDown = null
node.overIndex = null
}
return
}
// Draw individual
const img = imgs[imageIndex]
let w = img.naturalWidth
let h = img.naturalHeight
const scaleX = dw / w
const scaleY = dh / h
const scale = Math.min(scaleX, scaleY, 1)
w *= scale
h *= scale
const x = (dw - w) / 2
const y = (dh - h) / 2 + shiftY
ctx.drawImage(img, x, y, w, h)
// Draw image size text below the image
if (allowImageSizeDraw) {
ctx.fillStyle = LiteGraph.NODE_TEXT_COLOR
ctx.textAlign = 'center'
ctx.font = '10px sans-serif'
const sizeText = `${Math.round(img.naturalWidth)} × ${Math.round(img.naturalHeight)}`
const textY = y + h + 10
ctx.fillText(sizeText, x + w / 2, textY)
}
const drawButton = (
x: number,
y: number,
sz: number,
text: string
): boolean => {
const hovered = LiteGraph.isInsideRectangle(
mouse[0],
mouse[1],
x + node.pos[0],
y + node.pos[1],
sz,
sz
)
let fill = '#333'
let textFill = '#fff'
let isClicking = false
if (hovered) {
canvas.canvas.style.cursor = 'pointer'
if (canvas.pointer_is_down) {
fill = '#1e90ff'
isClicking = true
} else {
fill = '#eee'
textFill = '#000'
}
}
ctx.fillStyle = fill
ctx.beginPath()
ctx.roundRect(x, y, sz, sz, [4])
ctx.fill()
ctx.fillStyle = textFill
ctx.font = '12px Arial'
ctx.textAlign = 'center'
ctx.fillText(text, x + 15, y + 20)
return isClicking
}
if (!(numImages > 1)) return
const imageNum = (node.imageIndex ?? 0) + 1
if (drawButton(dw - 40, dh + shiftY - 40, 30, `${imageNum}/${numImages}`)) {
const i = imageNum >= numImages ? 0 : imageNum
if (!node.pointerDown || node.pointerDown.index !== i) {
node.pointerDown = { index: i, pos: [...mouse] }
}
}
if (drawButton(dw - 40, shiftY + 10, 30, `x`)) {
if (!node.pointerDown || node.pointerDown.index !== null) {
node.pointerDown = { index: null, pos: [...mouse] }
}
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
return {
minHeight: 220,
minWidth: 1
}
}
}
export const useImagePreviewWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
serialize: false
})
)
}
return widgetConstructor
}

View File

@@ -0,0 +1,121 @@
import { useNodeImage, useNodeVideo } from '@/composables/node/useNodeImage'
import { useNodeImageUpload } from '@/composables/node/useNodeImageUpload'
import { useValueTransform } from '@/composables/useValueTransform'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
import type { InputSpec } from '@/schemas/nodeDefSchema'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
import { createAnnotatedPath } from '@/utils/formatUtil'
import { addToComboValues } from '@/utils/litegraphUtil'
const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp'
const ACCEPTED_VIDEO_TYPES = 'video/webm,video/mp4'
type InternalFile = string | ResultItem
type InternalValue = InternalFile | InternalFile[]
type ExposedValue = string | string[]
const isImageFile = (file: File) => file.type.startsWith('image/')
const isVideoFile = (file: File) => file.type.startsWith('video/')
const findFileComboWidget = (node: LGraphNode, inputName: string) =>
node.widgets!.find((w) => w.name === inputName) as IComboWidget & {
value: ExposedValue
}
export const useImageUploadWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = (
node: LGraphNode,
inputName: string,
inputData: InputSpec
) => {
if (!isImageUploadInput(inputData)) {
throw new Error(
'Image upload widget requires imageInputName augmentation'
)
}
const inputOptions = inputData[1]
const { imageInputName, allow_batch, image_folder = 'input' } = inputOptions
const folder: ResultItemType | undefined = image_folder
const nodeOutputStore = useNodeOutputStore()
const isAnimated = !!inputOptions.animated_image_upload
const isVideo = !!inputOptions.video_upload
const accept = isVideo ? ACCEPTED_VIDEO_TYPES : ACCEPTED_IMAGE_TYPES
const { showPreview } = isVideo ? useNodeVideo(node) : useNodeImage(node)
const fileFilter = isVideo ? isVideoFile : isImageFile
const fileComboWidget = findFileComboWidget(node, imageInputName)
const initialFile = `${fileComboWidget.value}`
const formatPath = (value: InternalFile) =>
createAnnotatedPath(value, { rootFolder: image_folder })
const transform = (internalValue: InternalValue): ExposedValue => {
if (!internalValue) return initialFile
if (Array.isArray(internalValue))
return allow_batch
? internalValue.map(formatPath)
: formatPath(internalValue[0])
return formatPath(internalValue)
}
Object.defineProperty(
fileComboWidget,
'value',
useValueTransform(transform, initialFile)
)
// Setup file upload handling
const { openFileSelection } = useNodeImageUpload(node, {
allow_batch,
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
// @ts-expect-error litegraph combo value type does not support arrays yet
fileComboWidget.value = output
fileComboWidget.callback?.(output)
}
})
// Create the button widget for selecting the files
const uploadWidget = node.addWidget(
'button',
inputName,
'image',
() => openFileSelection(),
{
serialize: false
}
)
uploadWidget.label = t('g.choose_file_to_upload')
// Add our own callback to the combo widget to render an image when it changes
fileComboWidget.callback = function () {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
node.graph?.setDirtyCanvas(true)
}
// On load if we have a value then render the image
// The value isnt set immediately so we need to wait a moment
// No change callbacks seem to be fired on initial setting of the value
requestAnimationFrame(() => {
nodeOutputStore.setNodeOutputs(node, fileComboWidget.value, {
isAnimated
})
showPreview({ block: false })
})
return { widget: uploadWidget }
}
return widgetConstructor
}

View File

@@ -0,0 +1,97 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import {
type InputSpec,
isIntInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type ComfyWidgetConstructorV2,
addValueControlWidget
} from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
function onValueChange(this: INumericWidget, v: number) {
// For integers, always round to the nearest step
// step === 0 is invalid, assign 1 if options.step is 0
const step = this.options.step2 || 1
if (step === 1) {
// Simple case: round to nearest integer
this.value = Math.round(v)
} else {
// Round to nearest multiple of step
// First, determine if min value creates an offset
const min = this.options.min ?? 0
const offset = min % step
// Round to nearest step, accounting for offset
this.value = Math.round((v - offset) / step) * step + offset
}
}
export const _for_testing = {
onValueChange
}
export const useIntWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isIntInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const settingStore = useSettingStore()
const sliderEnabled = !settingStore.get('Comfy.DisableSliders')
const display_type = inputSpec.display
const widgetType =
sliderEnabled && display_type == 'slider'
? 'slider'
: display_type == 'knob'
? 'knob'
: 'number'
const step = inputSpec.step ?? 1
/** Assertion {@link inputSpec.default} */
const defaultValue = (inputSpec.default as number | undefined) ?? 0
const widget = node.addWidget(
widgetType,
inputSpec.name,
defaultValue,
onValueChange,
{
min: inputSpec.min ?? 0,
max: inputSpec.max ?? 2048,
/** @deprecated Use step2 instead. The 10x value is a legacy implementation. */
step: step * 10,
step2: step,
precision: 0
}
)
const controlAfterGenerate =
inputSpec.control_after_generate ??
/**
* Compatibility with legacy node convention. Int input with name
* 'seed' or 'noise_seed' get automatically added a control widget.
*/
['seed', 'noise_seed'].includes(inputSpec.name)
if (controlAfterGenerate) {
const seedControl = addValueControlWidget(
node,
widget,
'randomize',
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
widget.linkedWidgets = [seedControl]
}
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,115 @@
import { Editor as TiptapEditor } from '@tiptap/core'
import TiptapLink from '@tiptap/extension-link'
import TiptapTable from '@tiptap/extension-table'
import TiptapTableCell from '@tiptap/extension-table-cell'
import TiptapTableHeader from '@tiptap/extension-table-header'
import TiptapTableRow from '@tiptap/extension-table-row'
import TiptapStarterKit from '@tiptap/starter-kit'
import { Markdown as TiptapMarkdown } from 'tiptap-markdown'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { type InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
function addMarkdownWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string }
) {
TiptapMarkdown.configure({
html: false,
breaks: true,
transformPastedText: true
})
const editor = new TiptapEditor({
extensions: [
TiptapStarterKit,
TiptapMarkdown,
TiptapLink,
TiptapTable,
TiptapTableCell,
TiptapTableHeader,
TiptapTableRow
],
content: opts.defaultVal,
editable: false
})
const inputEl = editor.options.element as HTMLElement
inputEl.classList.add('comfy-markdown')
const textarea = document.createElement('textarea')
inputEl.append(textarea)
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
getValue(): string {
return textarea.value
},
setValue(v: string) {
textarea.value = v
editor.commands.setContent(v)
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button !== 0) {
app.canvas.processMouseDown(event)
return
}
if (event.target instanceof HTMLAnchorElement) {
return
}
inputEl.classList.add('editing')
setTimeout(() => {
textarea.focus()
}, 0)
})
textarea.addEventListener('blur', () => {
inputEl.classList.remove('editing')
})
textarea.addEventListener('change', () => {
editor.commands.setContent(textarea.value)
widget.callback?.(widget.value)
})
inputEl.addEventListener('keydown', (event: KeyboardEvent) => {
event.stopPropagation()
})
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
return widget
}
export const useMarkdownWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
return addMarkdownWidget(node, inputSpec.name, {
defaultVal: inputSpec.default ?? ''
})
}
return widgetConstructor
}

View File

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

View File

@@ -0,0 +1,55 @@
import { ref } from 'vue'
import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
ComponentWidgetImpl,
type ComponentWidgetStandardProps,
addWidget
} from '@/scripts/domWidget'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
type TextPreviewCustomProps = Omit<
InstanceType<typeof TextPreviewWidget>['$props'],
ComponentWidgetStandardProps
>
const PADDING = 16
export const useTextPreviewWidget = (
options: {
minHeight?: number
} = {}
) => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
const widgetValue = ref<string>('')
const widget = new ComponentWidgetImpl<
string | object,
TextPreviewCustomProps
>({
node,
name: inputSpec.name,
component: TextPreviewWidget,
inputSpec,
props: {
nodeId: node.id
},
options: {
getValue: () => widgetValue.value,
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
}
})
addWidget(node, widget)
return widget
}
return widgetConstructor
}

View File

@@ -0,0 +1,274 @@
import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { IWidget } from '@/lib/litegraph/src/litegraph'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
const MAX_RETRIES = 5
const TIMEOUT = 4096
export interface CacheEntry<T> {
data: T
timestamp?: number
error?: Error | null
fetchPromise?: Promise<T>
controller?: AbortController
lastErrorTime?: number
retryCount?: number
failed?: boolean
}
const dataCache = new Map<string, CacheEntry<any>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
const { route, query_params = {}, refresh = 0 } = config
const paramsKey = Object.entries(query_params)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}=${v}`)
.join('&')
return [route, `r=${refresh}`, paramsKey].join(';')
}
const getBackoff = (retryCount: number) =>
Math.min(1000 * Math.pow(2, retryCount), 512)
const isInitialized = (entry: CacheEntry<unknown> | undefined) =>
entry?.data && entry?.timestamp && entry.timestamp > 0
const isStale = (entry: CacheEntry<unknown> | undefined, ttl: number) =>
entry?.timestamp && Date.now() - entry.timestamp >= ttl
const isFetching = (entry: CacheEntry<unknown> | undefined) =>
entry?.fetchPromise !== undefined
const isFailed = (entry: CacheEntry<unknown> | undefined) =>
entry?.failed === true
const isBackingOff = (entry: CacheEntry<unknown> | undefined) =>
entry?.error &&
entry?.lastErrorTime &&
Date.now() - entry.lastErrorTime < getBackoff(entry.retryCount || 0)
const fetchData = async (
config: RemoteWidgetConfig,
controller: AbortController
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const res = await axios.get(route, {
params: query_params,
signal: controller.signal,
timeout
})
return response_key ? res.data[response_key] : res.data
}
export function useRemoteWidget<
T extends string | number | boolean | object
>(options: {
remoteConfig: RemoteWidgetConfig
defaultValue: T
node: LGraphNode
widget: IWidget
}) {
const { remoteConfig, defaultValue, node, widget } = options
const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const isPermanent = refresh <= 0
const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false
let refreshQueued = false
const setSuccess = (entry: CacheEntry<T>, data: T) => {
entry.retryCount = 0
entry.lastErrorTime = 0
entry.error = null
entry.timestamp = Date.now()
entry.data = data ?? defaultValue
}
const setError = (entry: CacheEntry<T>, error: Error | unknown) => {
entry.retryCount = (entry.retryCount || 0) + 1
entry.lastErrorTime = Date.now()
entry.error = error instanceof Error ? error : new Error(String(error))
entry.data ??= defaultValue
entry.fetchPromise = undefined
if (entry.retryCount >= max_retries) {
setFailed(entry)
}
}
const setFailed = (entry: CacheEntry<T>) => {
dataCache.set(cacheKey, {
data: entry.data ?? defaultValue,
failed: true
})
}
const isFirstLoad = () => {
return !isLoaded && isInitialized(dataCache.get(cacheKey))
}
const onFirstLoad = (data: T[]) => {
isLoaded = true
widget.value = data[0]
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
const fetchValue = async () => {
const entry = dataCache.get(cacheKey)
if (isFailed(entry)) return entry!.data
const isValid =
isInitialized(entry) && (isPermanent || !isStale(entry, refresh))
if (isValid || isBackingOff(entry) || isFetching(entry)) return entry!.data
const currentEntry: CacheEntry<T> = entry || { data: defaultValue }
dataCache.set(cacheKey, currentEntry)
try {
currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data)
return currentEntry.data
} catch (err) {
setError(currentEntry, err)
return currentEntry.data
} finally {
currentEntry.fetchPromise = undefined
currentEntry.controller = undefined
}
}
const onRefresh = () => {
if (remoteConfig.control_after_refresh) {
const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (remoteConfig.control_after_refresh) {
case 'first':
widget.value = data[0] ?? defaultValue
break
case 'last':
widget.value = data.at(-1) ?? defaultValue
break
}
widget.callback?.(widget.value)
node.graph?.setDirtyCanvas(true)
}
}
/**
* Clear the widget's cached value, forcing a refresh on next access (e.g., a new render)
*/
const clearCachedValue = () => {
const entry = dataCache.get(cacheKey)
if (!entry) return
if (entry.fetchPromise) entry.controller?.abort() // Abort in-flight request
dataCache.delete(cacheKey)
}
/**
* Get the cached value of the widget without starting a new fetch.
* @returns the most recently computed value of the widget.
*/
function getCachedValue() {
return dataCache.get(cacheKey)?.data as T
}
/**
* Getter of the remote property of the widget (e.g., options.values, value, etc.).
* Starts the fetch process then returns the cached value immediately.
* @returns the most recent value of the widget.
*/
function getValue(onFulfilled?: () => void) {
void fetchValue()
.then((data) => {
if (isFirstLoad()) onFirstLoad(data)
if (refreshQueued && data !== defaultValue) {
onRefresh()
refreshQueued = false
}
onFulfilled?.()
})
.catch((err) => {
console.error(err)
})
return getCachedValue() ?? defaultValue
}
/**
* Force the widget to refresh its value
*/
widget.refresh = function () {
refreshQueued = true
clearCachedValue()
getValue()
}
/**
* Add a refresh button to the node that, when clicked, will force the widget to refresh
*/
function addRefreshButton() {
node.addWidget('button', 'refresh', 'refresh', widget.refresh)
}
/**
* Add auto-refresh toggle widget and execution success listener
*/
function addAutoRefreshToggle() {
let autoRefreshEnabled = false
// Handler for execution success
const handleExecutionSuccess = () => {
if (autoRefreshEnabled && widget.refresh) {
widget.refresh()
}
}
// Add toggle widget
const autoRefreshWidget = node.addWidget(
'toggle',
'Auto-refresh after generation',
false,
(value: boolean) => {
autoRefreshEnabled = value
},
{
serialize: false
}
)
// Register event listener
api.addEventListener('execution_success', handleExecutionSuccess)
// Cleanup on node removal
node.onRemoved = useChainCallback(node.onRemoved, function () {
api.removeEventListener('execution_success', handleExecutionSuccess)
})
return autoRefreshWidget
}
// Always add auto-refresh toggle for remote widgets
addAutoRefreshToggle()
return {
getCachedValue,
getValue,
refreshValue: widget.refresh,
addRefreshButton,
getCacheEntry: () => dataCache.get(cacheKey),
cacheKey
}
}

View File

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

View File

@@ -0,0 +1,139 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
type InputSpec,
isStringInputSpec
} from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useSettingStore } from '@/stores/settingStore'
const TRACKPAD_DETECTION_THRESHOLD = 50
function addMultilineWidget(
node: LGraphNode,
name: string,
opts: { defaultVal: string; placeholder?: string }
) {
const inputEl = document.createElement('textarea')
inputEl.className = 'comfy-multiline-input'
inputEl.value = opts.defaultVal
inputEl.placeholder = opts.placeholder || name
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
getValue(): string {
return inputEl.value
},
setValue(v: string) {
inputEl.value = v
}
})
widget.inputEl = inputEl
widget.options.minNodeSize = [400, 200]
inputEl.addEventListener('input', () => {
widget.callback?.(widget.value)
})
// Allow middle mouse button panning
inputEl.addEventListener('pointerdown', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseDown(event)
}
})
inputEl.addEventListener('pointermove', (event: PointerEvent) => {
if ((event.buttons & 4) === 4) {
app.canvas.processMouseMove(event)
}
})
inputEl.addEventListener('pointerup', (event: PointerEvent) => {
if (event.button === 1) {
app.canvas.processMouseUp(event)
}
})
inputEl.addEventListener('wheel', (event: WheelEvent) => {
const gesturesEnabled = useSettingStore().get(
'LiteGraph.Pointer.TrackpadGestures'
)
const deltaX = event.deltaX
const deltaY = event.deltaY
const canScrollY = inputEl.scrollHeight > inputEl.clientHeight
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY)
// Prevent pinch zoom from zooming the page
if (event.ctrlKey) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Detect if this is likely a trackpad gesture vs mouse wheel
// Trackpads usually have deltaX or smaller deltaY values (< TRACKPAD_DETECTION_THRESHOLD)
// Mouse wheels typically have larger discrete deltaY values (>= TRACKPAD_DETECTION_THRESHOLD)
const isLikelyTrackpad =
Math.abs(deltaX) > 0 || Math.abs(deltaY) < TRACKPAD_DETECTION_THRESHOLD
// Trackpad gestures: when enabled, trackpad panning goes to canvas
if (gesturesEnabled && isLikelyTrackpad) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// When gestures disabled: horizontal always goes to canvas (no horizontal scroll in textarea)
if (isHorizontal) {
event.preventDefault()
event.stopPropagation()
app.canvas.processMouseWheel(event)
return
}
// Vertical scrolling when gestures disabled: let textarea scroll if scrollable
if (canScrollY) {
event.stopPropagation()
return
}
// If textarea can't scroll vertically, pass to canvas
event.preventDefault()
app.canvas.processMouseWheel(event)
})
return widget
}
export const useStringWidget = () => {
const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode,
inputSpec: InputSpec
) => {
if (!isStringInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputSpec}`)
}
const defaultVal = inputSpec.default ?? ''
const multiline = inputSpec.multiline
const widget = multiline
? addMultilineWidget(node, inputSpec.name, {
defaultVal,
placeholder: inputSpec.placeholder
})
: node.addWidget('text', inputSpec.name, defaultVal, () => {}, {})
if (typeof inputSpec.dynamicPrompts === 'boolean') {
widget.dynamicPrompts = inputSpec.dynamicPrompts
}
return widget
}
return widgetConstructor
}

View File

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

View File

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

View File

@@ -0,0 +1,150 @@
/**
* Widget type registry and component mapping for Vue-based widgets
*/
import type { Component } from 'vue'
import WidgetButton from '../components/WidgetButton.vue'
import WidgetChart from '../components/WidgetChart.vue'
import WidgetColorPicker from '../components/WidgetColorPicker.vue'
import WidgetFileUpload from '../components/WidgetFileUpload.vue'
import WidgetGalleria from '../components/WidgetGalleria.vue'
import WidgetImageCompare from '../components/WidgetImageCompare.vue'
import WidgetInputText from '../components/WidgetInputText.vue'
import WidgetMarkdown from '../components/WidgetMarkdown.vue'
import WidgetMultiSelect from '../components/WidgetMultiSelect.vue'
import WidgetSelect from '../components/WidgetSelect.vue'
import WidgetSelectButton from '../components/WidgetSelectButton.vue'
import WidgetSlider from '../components/WidgetSlider.vue'
import WidgetTextarea from '../components/WidgetTextarea.vue'
import WidgetToggleSwitch from '../components/WidgetToggleSwitch.vue'
import WidgetTreeSelect from '../components/WidgetTreeSelect.vue'
interface WidgetDefinition {
component: Component
aliases: string[]
essential: boolean
}
const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
[
'button',
{ component: WidgetButton, aliases: ['BUTTON'], essential: false }
],
[
'string',
{
component: WidgetInputText,
aliases: ['STRING', 'text'],
essential: false
}
],
['int', { component: WidgetSlider, aliases: ['INT'], essential: true }],
[
'float',
{
component: WidgetSlider,
aliases: ['FLOAT', 'number', 'slider'],
essential: true
}
],
[
'boolean',
{
component: WidgetToggleSwitch,
aliases: ['BOOLEAN', 'toggle'],
essential: true
}
],
['combo', { component: WidgetSelect, aliases: ['COMBO'], essential: true }],
[
'color',
{ component: WidgetColorPicker, aliases: ['COLOR'], essential: false }
],
[
'multiselect',
{ component: WidgetMultiSelect, aliases: ['MULTISELECT'], essential: false }
],
[
'selectbutton',
{
component: WidgetSelectButton,
aliases: ['SELECTBUTTON'],
essential: false
}
],
[
'textarea',
{
component: WidgetTextarea,
aliases: ['TEXTAREA', 'multiline', 'customtext'],
essential: false
}
],
['chart', { component: WidgetChart, aliases: ['CHART'], essential: false }],
[
'imagecompare',
{
component: WidgetImageCompare,
aliases: ['IMAGECOMPARE'],
essential: false
}
],
[
'galleria',
{ component: WidgetGalleria, aliases: ['GALLERIA'], essential: false }
],
[
'fileupload',
{
component: WidgetFileUpload,
aliases: ['FILEUPLOAD', 'file'],
essential: false
}
],
[
'treeselect',
{ component: WidgetTreeSelect, aliases: ['TREESELECT'], essential: false }
],
[
'markdown',
{ component: WidgetMarkdown, aliases: ['MARKDOWN'], essential: false }
]
]
// Build lookup maps
const widgets = new Map<string, WidgetDefinition>()
const aliasMap = new Map<string, string>()
for (const [type, def] of coreWidgetDefinitions) {
widgets.set(type, def)
for (const alias of def.aliases) {
aliasMap.set(alias, type)
}
}
// Utility functions
const getCanonicalType = (type: string): string => aliasMap.get(type) || type
export const getComponent = (type: string): Component | null => {
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.component || null
}
export const isSupported = (type: string): boolean => {
const canonicalType = getCanonicalType(type)
return widgets.has(canonicalType)
}
export const isEssential = (type: string): boolean => {
const canonicalType = getCanonicalType(type)
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: {
type?: string
options?: Record<string, unknown>
}): boolean => {
if (widget.options?.canvasOnly) return false
if (!widget.type) return false
return isSupported(widget.type)
}