mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-10 23:50:00 +00:00
[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 commitd7ed1d36ed. * 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 commit460493a620. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit2b9d83efb8. * Revert "Remove slots from layoutTypes" This reverts commit18f78ff786. * Reapply "Add node slots to layout tree" This reverts commit236fecb549. * Revert "Add node slots to layout tree" This reverts commit460493a620. * 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 commit460493a620. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit2b9d83efb8. * Revert "Remove slots from layoutTypes" This reverts commit18f78ff786. * Reapply "Add node slots to layout tree" This reverts commit236fecb549. * Revert "Add node slots to layout tree" This reverts commit460493a620. * 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 commit460493a620. * 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 commit70fbfd0f5e. * [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 commit84e7102f(#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 commit460493a620. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit2b9d83efb8. * Revert "Remove slots from layoutTypes" This reverts commit18f78ff786. * Reapply "Add node slots to layout tree" This reverts commit236fecb549. * Revert "Add node slots to layout tree" This reverts commit460493a620. * 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 commit460493a620. * Remove slots from layoutTypes * Totally not scuffed renderer and adapter * Revert "Totally not scuffed renderer and adapter" This reverts commit2b9d83efb8. * Revert "Remove slots from layoutTypes" This reverts commit18f78ff786. * Reapply "Add node slots to layout tree" This reverts commit236fecb549. * Revert "Add node slots to layout tree" This reverts commit460493a620. * 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 commit460493a620. * 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 commit70fbfd0f5e. * 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:
107
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal file
107
src/renderer/extensions/vueNodes/components/InputSlot.vue
Normal 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>
|
||||
271
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal file
271
src/renderer/extensions/vueNodes/components/LGraphNode.vue
Normal 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>
|
||||
40
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal file
40
src/renderer/extensions/vueNodes/components/NodeContent.vue
Normal 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>
|
||||
115
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal file
115
src/renderer/extensions/vueNodes/components/NodeHeader.vue
Normal 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>
|
||||
113
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal file
113
src/renderer/extensions/vueNodes/components/NodeSlots.vue
Normal 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>
|
||||
155
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal file
155
src/renderer/extensions/vueNodes/components/NodeWidgets.vue
Normal 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>
|
||||
106
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
106
src/renderer/extensions/vueNodes/components/OutputSlot.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
168
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal file
168
src/renderer/extensions/vueNodes/layout/useNodeLayout.ts
Normal 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'
|
||||
}))
|
||||
}
|
||||
}
|
||||
186
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal file
186
src/renderer/extensions/vueNodes/lod/useLOD.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(' ')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user