Compare commits

...

9 Commits

Author SHA1 Message Date
bymyself
7e51bc45f9 [feat] Implement Vue-based node rendering components
- LGraphNode: Main container with transform-based positioning
- NodeHeader: Collapsible title bar with dynamic coloring
- NodeSlots: Input/output connection visualization
- NodeWidgets: Integration with existing widget system
- NodeContent: Extensibility placeholder
- Error boundaries and performance optimizations (v-memo, CSS containment)
2025-06-24 18:25:08 -07:00
bymyself
7b25e230c6 [feat] Add slot type color definitions
- Centralized color mapping for node connection types
- Supports all ComfyUI slot types (model, clip, vae, etc.)
- Provides default fallback color
2025-06-24 18:20:10 -07:00
bymyself
a8602fce94 [docs] Add Vue node system architecture and implementation plans
- Implementation plan for Vue-based node rendering system
- Migration strategy from canvas to Vue components
- Widget system integration documentation
2025-06-24 18:19:58 -07:00
bymyself
56f59103a5 [feat] Add Vue action widgets
- WidgetButton: Action button with Button component and callback handling
- WidgetFileUpload: File upload interface with FileUpload component
2025-06-24 12:33:42 -07:00
bymyself
8129ba2132 [feat] Add Vue visual widgets
- WidgetColorPicker: Color selection with ColorPicker component
- WidgetImage: Single image display with Image component
- WidgetImageCompare: Before/after comparison with ImageCompare component
- WidgetGalleria: Image gallery/carousel with Galleria component
- WidgetChart: Data visualization with Chart component
2025-06-24 12:33:27 -07:00
bymyself
346cac0889 [feat] Add Vue selection widgets
- WidgetSelect: Dropdown selection with Select component
- WidgetMultiSelect: Multiple selection with MultiSelect component
- WidgetSelectButton: Button group selection with SelectButton component
- WidgetTreeSelect: Hierarchical selection with TreeSelect component
2025-06-24 12:33:11 -07:00
bymyself
7573bca6a2 [feat] Add Vue input widgets
- WidgetInputText: Single-line text input with InputText component
- WidgetTextarea: Multi-line text input with Textarea component
- WidgetSlider: Numeric range input with Slider component
- WidgetToggleSwitch: Boolean toggle with ToggleSwitch component
2025-06-24 12:32:54 -07:00
bymyself
c25206ad3b [feat] Add Vue widget registry system
- Complete widget type enum with all 15 widget types
- Component mapping registry for dynamic widget rendering
- Helper function for type-safe widget component resolution
2025-06-24 12:32:41 -07:00
bymyself
36e4e79994 [feat] Add core Vue widget infrastructure
- SimplifiedWidget interface for Vue-based node widgets
- widgetPropFilter utility with component-specific exclusion lists
- Removes DOM manipulation and positioning concerns
- Provides clean API for value binding and prop filtering
2025-06-24 12:32:27 -07:00
29 changed files with 2491 additions and 0 deletions

401
VUE_NODE_MIGRATION_PLAN.md Normal file
View File

@@ -0,0 +1,401 @@
# Vue Node Migration Plan
## Executive Summary
This plan outlines a phased migration from LiteGraph canvas rendering to Vue-based DOM rendering for ComfyUI nodes. Vue-based nodes allows access to component libraries (PrimeVue) and CSS frameworks, which increases iteration speed significantly. The migration preserves LiteGraph as the source of truth for graph logic while leveraging Vue components for rich, accessible node interfaces. Increased development speed will facilitate next gen UI/UX changes: .
## Goals and Objectives
### Primary Goals
- Enable rapid UI iteration with Vue's component model, PrimeVue, and CSS frameworks
- Maintain performance for workflows with 100+ nodes
- Preserve extension compatibility (90%+ without changes)
- Improve developer experience
### Success Metrics
- New components implementable in <1 hour (vs. current DOM manipulation)
- Performance regression <25% for 100-node workflows
- 90% of existing extensions work unmodified
- 3x faster UI development iteration
- Memory usage within 1.5x of canvas-only approach
### Non-Goals
- Complete canvas replacement (connections remain canvas-rendered)
- Mobile/touch optimization (separate initiative)
- Workflow format changes (must remain compatible)
- Extension API redesign (compatibility layer only)
### Definition of Done
- All core node types render via Vue components
- Canvas handles only connections and viewport
- Performance benchmarks meet targets
- Extension compatibility layer tested with top 20 extensions
- Migration guide published for extension developers whose extensions are broken by the migration
- Feature flag allows instant rollback
## Architecture Overview
### Current State
LiteGraph with mixed rendering approaches:
- **Canvas**: node bodies, connections, grid, selection
- **Widgets**: Three types coexist:
- Canvas widgets (drawn in 2D context)
- DOM widgets (manually positioned HTML elements)
- Vue widgets (components positioned via DOM)
- **Events**: Canvas handles most interactions, delegates to widgets
- **State**: Stored in LiteGraph node/graph objects
### Target State
Hybrid rendering with clear separation:
- **Canvas**: connections, grid, viewport pan/zoom, selection rectangles
- **DOM (Vue)**: all node contents and widgets unified as Vue components
- **Transform Pane**: single Vue-managed container synchronized with canvas transforms
- **State**: LiteGraph remains source of truth, Vue observes changes
### Hybrid Approach
During migration, both systems coexist:
- Feature flag controls Vue rendering per node type
- Canvas nodes and Vue nodes can connect normally
- Shared event system handles both rendering modes
- Progressive migration allows testing at each phase
## Technical Design
### Component Architecture
```
GraphCanvas.vue
└── TransformPane.vue (synchronized with canvas transforms)
└── LGraphNode.vue (v-for visible nodes)
├── NodeHeader.vue (title, controls)
├── NodeSlots.vue (input/output connection points)
│ ├── InputSlot.vue (connection target)
│ └── OutputSlot.vue (connection source)
├── NodeWidgets.vue (parameter controls)
│ └── [Widget components rendered here]
│ ├── NumberWidget.vue
│ ├── StringWidget.vue
│ ├── ComboWidget.vue
│ └── [etc...]
└── NodeContent.vue (custom content area)
```
Slots = connection points for node edges
Widgets = UI controls for node parameters
### State Management
- **One-way data flow**: LiteGraph → Vue components (props down, events up)
- Widget values flow from LiteGraph to Vue as props
- User interactions emit events that update LiteGraph
- Updated values flow back to Vue, completing the cycle
- **LiteGraph as source of truth**: All node/graph state remains in LiteGraph
- **Vue as view layer**: Components observe and reflect LiteGraph state
### Event System
- **Canvas events**: Pan, zoom, connection dragging, box selection
- **DOM events**: Node interactions, widget inputs, context menus
- **Transform sync**: No coordinate mapping needed - transforms handle positioning
- **(Future) Event delegation**: Single listener on TransformPane for efficiency
- **(Future) Touch handling**: Unified pointer events for mouse/touch consistency
### Positioning Strategy: CSS Transforms
For positioning nodes in the DOM, we'll use CSS transforms rather than absolute positioning with top/left. This decision is based on significant performance benefits validated by industry leaders (React Flow, Excalidraw, tldraw, Figma).
#### Core Implementation
```vue
<!-- TransformPane synchronized with canvas -->
<div class="transform-pane" :style="{ transform: `scale(${zoom}) translate(${panX}px, ${panY}px)` }">
<!-- Individual nodes use simple translate -->
<div v-for="node in visibleNodes"
:style="{ transform: `translate(${node.x}px, ${node.y}px)` }">
</div>
```
#### Key Optimizations
- **CSS Containment**: `contain: layout style paint` isolates node rendering
- **GPU Acceleration**: `will-change: transform` during interactions only
- **Batched Updates**: CSS custom properties for efficient updates
#### Handling PrimeVue Overlays
Portal strategy for components with fixed positioning:
```vue
<Teleport to="body" v-if="showOverlay">
<Popover :style="{ position: 'fixed', left: `${coords.x}px`, top: `${coords.y}px` }">
</Teleport>
```
#### Alternative Approaches
| Approach | Performance | Complexity | Use Case |
|----------|------------|------------|-----------|
| **CSS Transforms** | Excellent (GPU) | Medium | ✅ Our choice |
| **Absolute Position** | Poor (reflow) | Low | Small node counts |
| **Canvas Rendering** | Best | High | Not compatible with Vue |
| **SVG** | Good | Medium | Better for connections |
## Migration Strategy
### Phase 1: Widget Migration
For each widget:
- Create a new Vue component for the widget, using the API defined here: https://www.notion.so/drip-art/Widget-Componet-APIs-2126d73d365080b0bf30f241c09dd756
- If the widget existed before, alias the constructor to the new component (q: why not just replace entirely? any reason to keep the old constructor?)
- If the widget is new, create a new constructor for the widget and add to widgets.ts
- Implement the existing widget interface in the new component (i.e., create Vue-compatible mappings of the LG widget's props and events)
- Avoid components that use things like fixed positioning, teleport, `fill-available`, in the widget component (e.g., PrimeVue's Popover, Tooltip, Select) as they will require a portal strategy to work with transforms
### Phase 2: Node Migration
- Create a new Vue component for the node
- [maybe later] Create conditional render for LOD, distance cull, and viewport cull
- Implement the existing node interface in the new node component (i.e., create Vue-compatible mappings of the LG node's props and events)
### Phase 3: Transform Pane
- Create the transform pane
- Synchronize the transform pane with the canvas transforms
- Use `transform-origin` to position the transform pane in accordance with the canvas
- Use `will-change: transform` and verify with DevTools that nodes are on a single layer and not being promoted
- NOTE: in future, we need to actively prevent layer promotion (see promotion conditions: https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/compositing_reasons.h;l=18;drc=4e8e81f6eeb6969973f3ec97132d80339b92d227)
### Phase 4: Interaction System
- Map all existing events from previous interface
- Map all lifecycle hooks
- Add event delegation for transform pane events
- Restrict touch events to the transform pane
- For any event (except those affecting the entire transform pane) that affects compositing (e.g., moving nodes, resizing nodes), batch in RAF
### Phase 5: Portals
- Create a portal component that can be used to render components that use fixed positioning, teleport, `fill-available`, etc.
### Phase 6: Performance Optimizations
- Create baseline performance metrics
- For each optimization, test and iterate while comparing against baseline:
- Implement viewport culling or LOD
- It may be less efficient to cull nodes if we are only doing compositing and not actually recalc and reflow. This must be manually verified. In any case, there is still probably a very large threshold to implement viewport culling by, but it may be the case that it's not worth the effort to do.
- Implement distance culling and LOD
- (optional) Implement virtualization, prefetching, prerendering, preloading, preconnecting if still necessary
### Phase 7: Extension Migration
- Review all of the most common touch points from extension space
- Determine if any compatibility mappings are still needed, then implement them and add to public API
- Gradually test with more and more frontend extensions
## Performance
### Benchmarking Strategy
- Use existing performance wrapper playwright testing strategy created in https://www.notion.so/drip-art/Analyze-Performance-Impact-of-only-using-Vue-widgets-20b6d73d36508080a14cea0b8dce7073?source=copy_link#20d6d73d365080409a8ccc68f501284e
- Or, a subset of it
### Optimization Techniques
- Use `will-change: transform` and `transform: translateZ(0)` to force GPU acceleration
- Use `contain: layout style paint` to tell the browser this element won't affect outside layout
- Use `transform-origin` to position the transform pane in accordance with the canvas
- ~~Use `transform: translate3d(round(var(--x), 1px), round(var(--y), 1px), 0)` to snap to pixels during non-animated states~~
### Scaling Targets
- 256 nodes full LOD
- 1000 nodes culled
### Production Monitoring
For desktop users who have consented to telemetry:
- **Mixpanel Events**: Track migration feature adoption and performance metrics
- Node rendering time percentiles (p50, p90, p99)
- Frame rate during node interactions
- Memory usage with different node counts
- **Sentry Performance**: Monitor real-world performance regressions
- Transaction traces for node operations
- Custom performance marks for render phases
- Error rates specific to Vue node rendering
## Extension Compatibility Plan
### Migration Guide
Comprehensive migration documentation will be published at https://docs.comfy.org including:
- Step-by-step migration instructions for common patterns
- Code examples for converting canvas widgets to Vue components
- API compatibility reference
- Performance optimization guidelines
### Compatibility Layer
[Placeholder: Supporting both rendering modes]
### Deprecation Timeline
[Placeholder: List things that will be deprecated fully]
### Developer Communication
- **Email notifications** via existing developer mailing list
- **Discord announcements** in dedicated devrel channel
- **Automated PRs** to affected repositories (if breaking changes required)
## Testing
### Testing Strategy
#### Component Tests
- Create component tests for each widget and node
#### Integration Tests
- Create integration tests for canvas and transform pane synchronization
#### Performance Tests
maybe
### Migration of Existing Test Suites
#### Unit Tests
[Placeholder: strategy for migrating unit tests]
#### Browser Tests
[Placeholder: strategy for migrating browser tests]
## Risk Mitigation
### Technical Risks
#### Performance Degradation
- **Risk**: DOM nodes significantly slower than canvas rendering
- **Mitigation**: Aggressive viewport culling, CSS containment, GPU acceleration
- **Monitoring**: Automated performance benchmarks on each PR
#### Memory Usage
- **Risk**: 1000+ DOM nodes consume excessive memory
- **Mitigation**: Component pooling, virtualization for large workflows
- **Detection**: Memory profiling in browser tests
#### Extension Breaking Changes
- **Risk**: Popular extensions stop working
- **Mitigation**: Compatibility layer maintaining critical APIs
- **Testing**: Top 20 extensions tested before each phase
#### State Synchronization Bugs
- **Risk**: LiteGraph and Vue state diverge
- **Mitigation**: Strict one-way data flow, comprehensive event testing
- **Prevention**: State invariant checks in development mode
### Rollback Plan
1. **Feature flag**: `enable_vue_nodes` setting (default: false)
2. **Gradual rollout**: Enable for specific node types first
3. **Quick revert**: Single flag disables all Vue rendering
4. **Data compatibility**: No changes to workflow format ensures backward compatibility
## Timeline and Milestones
### Week 1
[Placeholder: Initial milestones]
### Week 2
[Placeholder: Mid-term milestones]
### Week 3
[Placeholder: Final milestones]
## Open Questions
### Widget Constructor Aliasing
- Why keep old constructors vs. full replacement? (Phase 1, line 53)
- Is this for backwards compatibility with existing extensions?
### Viewport Culling Efficiency
- At what node count does viewport culling become beneficial? (Phase 6, line 89)
- Does the compositing-only benefit outweigh the Vue mount/unmount cost?
### Extension Compatibility
- How much of extension surface area to attempt to cover in compatibility layer?
- Which extension APIs are most critical to preserve?
### Transform Pane Synchronization
- How to handle canvas zoom/pan events?
- Should transform sync be RAF-batched or immediate?
### Event Delegation
- Which events stay on canvas vs. move to DOM?
- **Option A**: Canvas handles all drag/pan, DOM handles clicks/inputs
- **Option B**: DOM handles everything except connection dragging
- *Note: This option provides the best UX - users expect DOM elements to be fully interactive while keeping complex connection logic in canvas*
- **Option C**: Context-aware delegation based on interaction type
### LOD (Level of Detail) System
- What defines each LOD level?
- **High**: Full widgets and styling
- **Medium**: Simplified widgets, reduced effects?
- **Low**: Title and connections only?
- Transition triggers: zoom level, node count, or performance metrics?
- *Note: Zoom level as primary trigger is most predictable for users, with node count as override for performance protection*
### Interaction System Migration
- How to maintain gesture consistency between canvas and DOM nodes?
- Multi-select behavior across rendering boundaries?
## Appendices
### A. Prototype Learnings
Detailed findings and discoveries from process of developing Vue widgets and Vue nodes prototype (in vue-node-test branch)
- Constructing components easy, difficulty is performance and compatibility
### B. Performance and Browser Rendering
- https://www.notion.so/drip-art/Analyze-Performance-Impact-of-only-using-Vue-widgets-20b6d73d36508080a14cea0b8dce7073?source=copy_link#20d6d73d365080409a8ccc68f501284e
- https://source.chromium.org/chromium/chromium/src/+/refs/heads/main:third_party/blink/renderer/platform/graphics/compositing_reasons.h;l=18;drc=4e8e81f6eeb6969973f3ec97132d80339b92d227
- https://webperf.tips/tip/browser-rendering-pipeline/
- https://webperf.tips/tip/layers-and-compositing/
- https://webperf.tips/tip/layout-thrashing/
### C. API Design
- https://www.notion.so/drip-art/Widget-Componet-APIs-2126d73d365080b0bf30f241c09dd756

View File

@@ -0,0 +1,68 @@
<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 gap-2 py-1 pl-2 pr-4 cursor-crosshair hover:bg-black/5"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible
}"
@pointerdown="handleClick"
>
<!-- Connection Dot -->
<div
class="lg-slot__dot w-3 h-3 rounded-full border-2"
:style="{
backgroundColor: connected ? slotColor : 'transparent',
borderColor: slotColor
}"
/>
<!-- Slot Name -->
<span class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Input ${index}` }}
</span>
</div>
</template>
<script setup lang="ts">
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
import { getSlotColor } from '@/constants/slotColors'
interface InputSlotProps {
node: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
}
const props = defineProps<InputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue input slot error:', error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,168 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Render Error
</div>
<div
v-else
:class="[
'lg-node absolute border-2 rounded bg-surface-0',
'contain-layout contain-style contain-paint',
selected
? 'border-primary-500 ring-2 ring-primary-300'
: 'border-surface-300',
executing ? 'animate-pulse' : '',
node.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : ''
]"
:style="{
transform: `translate(${position.x}px, ${position.y}px)`,
width: node.size ? `${node.size[0]}px` : 'auto',
minWidth: '200px'
}"
@pointerdown="handlePointerDown"
>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[node.title, node.color]"
:node="node"
:readonly="readonly"
@collapse="handleCollapse"
/>
<!-- Node Body (only visible when not collapsed) -->
<div v-if="!node.flags?.collapsed" class="flex flex-col gap-2 p-2">
<!-- Slots only update when connections change -->
<NodeSlots
v-memo="[node.inputs?.length, node.outputs?.length]"
:node="node"
:readonly="readonly"
@slot-click="handleSlotClick"
/>
<!-- Widgets update on value changes -->
<NodeWidgets
v-if="node.widgets?.length"
v-memo="[
node.widgets?.length,
...(node.widgets?.map((w) => w.value) ?? [])
]"
:node="node"
:readonly="readonly"
/>
<!-- Custom content area -->
<NodeContent v-if="hasCustomContent" :node="node" :readonly="readonly" />
</div>
<!-- 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 type { LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, reactive, ref, watch } from 'vue'
import NodeContent from './NodeContent.vue'
import NodeHeader from './NodeHeader.vue'
import NodeSlots from './NodeSlots.vue'
import NodeWidgets from './NodeWidgets.vue'
// Extended props for main node component
interface LGraphNodeProps {
node: LGraphNode
readonly?: boolean
selected?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
const props = defineProps<LGraphNodeProps>()
const emit = defineEmits<{
'node-click': [event: PointerEvent, node: LGraphNode]
'slot-click': [
event: PointerEvent,
node: LGraphNode,
slotIndex: number,
isInput: boolean
]
collapse: []
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node component error:', error)
return false // Prevent error propagation
})
// Position state - initialized from node.pos but then controlled via transforms
const position = reactive({
x: props.node.pos[0],
y: props.node.pos[1]
})
// Track dragging state for will-change optimization
const isDragging = ref(false)
// Only update position when node.pos changes AND we're not dragging
// This prevents reflows during drag operations
watch(
() => props.node.pos,
(newPos) => {
if (!isDragging.value) {
position.x = newPos[0]
position.y = newPos[1]
}
},
{ deep: true }
)
// 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
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
emit('node-click', event, props.node)
// The parent component will handle setting isDragging when appropriate
}
const handleCollapse = () => {
// Parent component should handle node mutations
// This is just emitting the event upwards
emit('collapse')
}
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
emit('slot-click', event, props.node, slotIndex, isInput)
}
// Expose methods for parent to control position during drag
defineExpose({
setPosition(x: number, y: number) {
position.x = x
position.y = y
},
setDragging(dragging: boolean) {
isDragging.value = dragging
}
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
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 type { LGraphNode } from '@comfyorg/litegraph'
import { onErrorCaptured, ref } from 'vue'
interface NodeContentProps {
node: LGraphNode
readonly?: boolean
}
defineProps<NodeContentProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node content error:', error)
return false
})
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Header Error
</div>
<div
v-else
class="lg-node-header flex items-center justify-between px-3 py-2 rounded-t cursor-move"
:style="{
backgroundColor: headerColor,
color: textColor
}"
@dblclick="handleDoubleClick"
>
<!-- Node Title -->
<span class="text-sm font-medium truncate flex-1">
{{ node.title || node.constructor.title || 'Untitled' }}
</span>
<!-- Node Controls -->
<div class="flex items-center gap-1 ml-2">
<!-- Collapse/Expand Button -->
<button
v-if="!readonly"
class="lg-node-header__control p-0.5 rounded hover:bg-black/10 transition-colors"
:title="node.flags?.collapsed ? 'Expand' : 'Collapse'"
@click.stop="handleCollapse"
>
<svg
class="w-3 h-3 transition-transform"
:class="{ 'rotate-180': node.flags?.collapsed }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<!-- Additional controls can be added here -->
</div>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
interface NodeHeaderProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeHeaderProps>()
const emit = defineEmits<{
collapse: []
'title-edit': []
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node header error:', error)
return false
})
// Compute header color based on node color property or type
const headerColor = computed(() => {
if (props.node.color) {
return props.node.color
}
// Default color based on node mode
if (props.node.mode === 4) return '#666' // Bypassed
if (props.node.mode === 2) return '#444' // Muted
return '#353535' // Default
})
// Compute text color for contrast
const textColor = computed(() => {
// Simple contrast calculation - could be improved
const color = headerColor.value
if (!color || color === '#353535' || color === '#444' || color === '#666') {
return '#fff'
}
// For custom colors, use a simple heuristic
const rgb = parseInt(color.slice(1), 16)
const r = (rgb >> 16) & 255
const g = (rgb >> 8) & 255
const b = rgb & 255
const brightness = (r * 299 + g * 587 + b * 114) / 1000
return brightness > 128 ? '#000' : '#fff'
})
// Event handlers
const handleCollapse = () => {
emit('collapse')
}
const handleDoubleClick = () => {
if (!props.readonly) {
emit('title-edit')
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Slots Error
</div>
<div v-else class="lg-node-slots relative">
<!-- Input Slots -->
<div
v-if="node.inputs?.length"
class="lg-node-slots__inputs absolute left-0 top-0 flex flex-col"
>
<InputSlot
v-for="(input, index) in node.inputs"
:key="`input-${index}`"
:node="node"
:slot-data="input"
:index="index"
:connected="isInputConnected(index)"
:compatible="false"
:readonly="readonly"
@slot-click="(e) => handleSlotClick(e, index, true)"
/>
</div>
<!-- Output Slots -->
<div
v-if="node.outputs?.length"
class="lg-node-slots__outputs absolute right-0 top-0 flex flex-col"
>
<OutputSlot
v-for="(output, index) in node.outputs"
:key="`output-${index}`"
:node="node"
:slot-data="output"
:index="index"
:connected="isOutputConnected(index)"
:compatible="false"
:readonly="readonly"
@slot-click="(e) => handleSlotClick(e, index, false)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { onErrorCaptured, ref } from 'vue'
import InputSlot from './InputSlot.vue'
import OutputSlot from './OutputSlot.vue'
interface NodeSlotsProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeSlotsProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent, slotIndex: number, isInput: boolean]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node slots error:', error)
return false
})
// Check if input slot has a connection
const isInputConnected = (index: number) => {
return props.node.inputs?.[index]?.link != null
}
// Check if output slot has any connections
const isOutputConnected = (index: number) => {
return (props.node.outputs?.[index]?.links?.length ?? 0) > 0
}
// Handle slot click events
const handleSlotClick = (
event: PointerEvent,
slotIndex: number,
isInput: boolean
) => {
emit('slot-click', event, slotIndex, isInput)
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Widgets Error
</div>
<div v-else class="lg-node-widgets flex flex-col gap-2">
<component
:is="getWidgetComponent(widget)"
v-for="(widget, index) in widgets"
:key="`widget-${index}-${widget.name}`"
v-model="widget.value"
:widget="simplifiedWidget(widget)"
:readonly="readonly"
@update:model-value="(value: any) => handleWidgetUpdate(widget, value)"
/>
</div>
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { computed, onErrorCaptured, ref } from 'vue'
import {
WidgetType,
getWidgetComponent as getWidgetComponentFromRegistry
} from '@/components/graph/vueWidgets/widgetRegistry'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface NodeWidgetsProps {
node: LGraphNode
readonly?: boolean
}
const props = defineProps<NodeWidgetsProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node widgets error:', error)
return false
})
// Get non-hidden widgets
const widgets = computed(() => {
return props.node.widgets?.filter((w) => !w.options?.hidden) || []
})
// Map widget type to our widget registry
const getWidgetComponent = (widget: IBaseWidget) => {
// Map LiteGraph widget types to our WidgetType enum
const typeMapping: Record<string, WidgetType> = {
number: WidgetType.SLIDER,
float: WidgetType.SLIDER,
int: WidgetType.SLIDER,
string: WidgetType.STRING,
text: WidgetType.TEXTAREA,
combo: WidgetType.COMBO,
toggle: WidgetType.BOOLEAN,
boolean: WidgetType.BOOLEAN,
button: WidgetType.BUTTON,
color: WidgetType.COLOR,
image: WidgetType.IMAGE,
file: WidgetType.FILEUPLOAD
}
const widgetType = typeMapping[widget.type] || widget.type.toUpperCase()
return getWidgetComponentFromRegistry(widgetType) || null
}
// Convert LiteGraph widget to SimplifiedWidget interface
const simplifiedWidget = (widget: IBaseWidget): SimplifiedWidget => {
return {
name: widget.name,
type: widget.type,
value: widget.value,
options: widget.options,
callback: widget.callback
}
}
// Handle widget value updates
const handleWidgetUpdate = (widget: IBaseWidget, value: any) => {
widget.value = value
// Call widget callback if exists
if (widget.callback) {
widget.callback(value)
}
// Mark node as dirty for LiteGraph
if (props.node.onWidgetChanged) {
props.node.onWidgetChanged(widget.name, value, null, widget)
}
}
</script>

View File

@@ -0,0 +1,68 @@
<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 gap-2 py-1 pr-2 pl-4 cursor-crosshair hover:bg-black/5 justify-end"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible
}"
@pointerdown="handleClick"
>
<!-- Slot Name -->
<span class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Output ${index}` }}
</span>
<!-- Connection Dot -->
<div
class="lg-slot__dot w-3 h-3 rounded-full border-2"
:style="{
backgroundColor: connected ? slotColor : 'transparent',
borderColor: slotColor
}"
/>
</div>
</template>
<script setup lang="ts">
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
import { computed, onErrorCaptured, ref } from 'vue'
import { getSlotColor } from '@/constants/slotColors'
interface OutputSlotProps {
node: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
}
const props = defineProps<OutputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// Error boundary implementation
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue output slot error:', error)
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,38 @@
<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" @click="handleClick" />
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
BADGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// Button widgets don't have a v-model value, they trigger actions
const props = defineProps<{
widget: SimplifiedWidget<void>
readonly?: boolean
}>()
// Button specific excluded props
const BUTTON_EXCLUDED_PROPS = [...BADGE_EXCLUDED_PROPS, 'iconClass'] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, BUTTON_EXCLUDED_PROPS)
)
const handleClick = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback()
}
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<div class="p-4 border border-gray-300 dark-theme:border-gray-600 rounded">
<!-- Simple chart placeholder - can be enhanced with Chart.js when available -->
<div
v-if="!value || !Array.isArray(value.data)"
class="text-center text-gray-500 dark-theme:text-gray-400"
>
No chart data available
</div>
<div v-else class="space-y-2">
<div v-if="value.title" class="text-center font-semibold">
{{ value.title }}
</div>
<div class="space-y-1">
<div
v-for="(item, index) in value.data"
:key="index"
class="flex justify-between items-center"
>
<span class="text-sm">{{ item.label || `Item ${index + 1}` }}</span>
<div class="flex items-center gap-2">
<div
class="h-3 bg-blue-500 rounded"
:style="{
width: `${Math.max((item.value / maxValue) * 100, 5)}px`
}"
></div>
<span class="text-sm font-mono">{{ item.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
interface ChartData {
title?: string
data: Array<{
label: string
value: number
}>
}
const value = defineModel<ChartData>({ required: true })
defineProps<{
widget: SimplifiedWidget<ChartData>
readonly?: boolean
}>()
const maxValue = computed(() => {
if (!value.value?.data?.length) return 1
return Math.max(...value.value.data.map((item) => item.value))
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ColorPicker v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
// ColorPicker specific excluded props include panel/overlay classes
const COLOR_PICKER_EXCLUDED_PROPS = [...PANEL_EXCLUDED_PROPS] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, COLOR_PICKER_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<FileUpload
v-bind="filteredProps"
:disabled="readonly"
@upload="handleUpload"
@select="handleSelect"
@remove="handleRemove"
@clear="handleClear"
@error="handleError"
/>
</div>
</template>
<script setup lang="ts">
import FileUpload from 'primevue/fileupload'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
// FileUpload doesn't have a traditional v-model, it handles files through events
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
const handleUpload = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleSelect = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleRemove = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
}
const handleClear = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback([])
}
}
const handleError = (event: any) => {
// Could be extended to handle error reporting
console.warn('File upload error:', event)
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Galleria
v-model:activeIndex="activeIndex"
:value="galleryImages"
v-bind="filteredProps"
:disabled="readonly"
:show-thumbnails="showThumbnails"
:show-indicators="showIndicators"
:show-nav-buttons="showNavButtons"
class="max-w-full"
>
<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 }">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-16 h-16 object-cover"
/>
</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 showIndicators = computed(() => {
return (
props.widget.options?.showIndicators !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
galleryImages.value.length > 1
)
})
</script>

View File

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

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<div
class="image-compare-container relative overflow-hidden rounded border border-gray-300 dark-theme:border-gray-600"
>
<div
v-if="!beforeImage || !afterImage"
class="p-4 text-center text-gray-500 dark-theme:text-gray-400"
>
Before and after images required
</div>
<div v-else class="relative">
<!-- After image (base layer) -->
<Image
v-bind="filteredProps"
:src="afterImage"
class="w-full h-auto"
:alt="afterAlt"
/>
<!-- Before image (overlay layer) -->
<div
class="absolute top-0 left-0 h-full overflow-hidden transition-all duration-300 ease-in-out"
:style="{ width: `${sliderPosition}%` }"
>
<Image
v-bind="filteredProps"
:src="beforeImage"
class="w-full h-auto"
:alt="beforeAlt"
/>
</div>
<!-- Slider handle -->
<div
class="absolute top-0 h-full w-0.5 bg-white shadow-lg cursor-col-resize z-10 transition-all duration-100"
:style="{ left: `${sliderPosition}%` }"
@mousedown="startDrag"
@touchstart="startDrag"
>
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-8 h-8 bg-white rounded-full shadow-md flex items-center justify-center"
>
<div class="w-4 h-4 flex items-center justify-center">
<div class="w-0.5 h-3 bg-gray-600 mr-0.5"></div>
<div class="w-0.5 h-3 bg-gray-600"></div>
</div>
</div>
</div>
<!-- Labels -->
<div
v-if="showLabels"
class="absolute top-2 left-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
>
{{ beforeLabel }}
</div>
<div
v-if="showLabels"
class="absolute top-2 right-2 px-2 py-1 bg-black bg-opacity-50 text-white text-xs rounded"
>
{{ afterLabel }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Image from 'primevue/image'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
IMAGE_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
interface ImageCompareValue {
before: string
after: string
beforeAlt?: string
afterAlt?: string
beforeLabel?: string
afterLabel?: string
showLabels?: boolean
initialPosition?: number
}
// Image compare widgets typically don't have v-model, they display comparison
const props = defineProps<{
widget: SimplifiedWidget<ImageCompareValue>
readonly?: boolean
}>()
const sliderPosition = ref(50) // Default to 50% (middle)
const isDragging = ref(false)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, IMAGE_EXCLUDED_PROPS)
)
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 : value?.after
})
const beforeAlt = computed(
() => props.widget.value?.beforeAlt || 'Before image'
)
const afterAlt = computed(() => props.widget.value?.afterAlt || 'After image')
const beforeLabel = computed(() => props.widget.value?.beforeLabel || 'Before')
const afterLabel = computed(() => props.widget.value?.afterLabel || 'After')
const showLabels = computed(() => props.widget.value?.showLabels !== false)
onMounted(() => {
if (props.widget.value?.initialPosition !== undefined) {
sliderPosition.value = Math.max(
0,
Math.min(100, props.widget.value.initialPosition)
)
}
})
const startDrag = (event: MouseEvent | TouchEvent) => {
if (props.readonly) return
isDragging.value = true
event.preventDefault()
const handleMouseMove = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
updateSliderPosition(e)
}
const handleMouseUp = () => {
isDragging.value = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('touchmove', handleMouseMove)
document.removeEventListener('touchend', handleMouseUp)
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('touchmove', handleMouseMove)
document.addEventListener('touchend', handleMouseUp)
}
const updateSliderPosition = (event: MouseEvent | TouchEvent) => {
const container = (event.target as HTMLElement).closest(
'.image-compare-container'
)
if (!container) return
const rect = container.getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const x = clientX - rect.left
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
sliderPosition.value = percentage
}
onUnmounted(() => {
isDragging.value = false
})
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<InputText v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<MultiSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any[]>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any[]>
readonly?: boolean
}>()
// MultiSelect specific excluded props include overlay styles
const MULTISELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'overlayStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, MULTISELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Select v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Select from 'primevue/select'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<SelectButton v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Slider v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Slider from 'primevue/slider'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<number>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<number>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Textarea v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import Textarea from 'primevue/textarea'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<string>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, INPUT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<ToggleSwitch v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
STANDARD_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<boolean>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<boolean>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<TreeSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
</div>
</template>
<script setup lang="ts">
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<any>
readonly?: boolean
}>()
// TreeSelect specific excluded props
const TREE_SELECT_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, TREE_SELECT_EXCLUDED_PROPS)
)
</script>

View File

@@ -0,0 +1,80 @@
/**
* Widget type registry and component mapping for Vue-based widgets
*/
import type { Component } from 'vue'
// Component imports
import WidgetButton from './WidgetButton.vue'
import WidgetChart from './WidgetChart.vue'
import WidgetColorPicker from './WidgetColorPicker.vue'
import WidgetFileUpload from './WidgetFileUpload.vue'
import WidgetGalleria from './WidgetGalleria.vue'
import WidgetImage from './WidgetImage.vue'
import WidgetImageCompare from './WidgetImageCompare.vue'
import WidgetInputText from './WidgetInputText.vue'
import WidgetMultiSelect from './WidgetMultiSelect.vue'
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectButton from './WidgetSelectButton.vue'
import WidgetSlider from './WidgetSlider.vue'
import WidgetTextarea from './WidgetTextarea.vue'
import WidgetToggleSwitch from './WidgetToggleSwitch.vue'
import WidgetTreeSelect from './WidgetTreeSelect.vue'
/**
* Enum of all available widget types
*/
export enum WidgetType {
BUTTON = 'BUTTON',
STRING = 'STRING',
INT = 'INT',
FLOAT = 'FLOAT',
NUMBER = 'NUMBER',
BOOLEAN = 'BOOLEAN',
COMBO = 'COMBO',
COLOR = 'COLOR',
MULTISELECT = 'MULTISELECT',
SELECTBUTTON = 'SELECTBUTTON',
SLIDER = 'SLIDER',
TEXTAREA = 'TEXTAREA',
TOGGLESWITCH = 'TOGGLESWITCH',
CHART = 'CHART',
IMAGE = 'IMAGE',
IMAGECOMPARE = 'IMAGECOMPARE',
GALLERIA = 'GALLERIA',
FILEUPLOAD = 'FILEUPLOAD',
TREESELECT = 'TREESELECT'
}
/**
* Maps widget types to their corresponding Vue components
* Components will be added as they are implemented
*/
export const widgetTypeToComponent: Record<string, Component> = {
// Components will be uncommented as they are implemented
[WidgetType.BUTTON]: WidgetButton,
[WidgetType.STRING]: WidgetInputText,
[WidgetType.INT]: WidgetSlider,
[WidgetType.FLOAT]: WidgetSlider,
[WidgetType.NUMBER]: WidgetSlider, // For compatibility
[WidgetType.BOOLEAN]: WidgetToggleSwitch,
[WidgetType.COMBO]: WidgetSelect,
[WidgetType.COLOR]: WidgetColorPicker,
[WidgetType.MULTISELECT]: WidgetMultiSelect,
[WidgetType.SELECTBUTTON]: WidgetSelectButton,
[WidgetType.SLIDER]: WidgetSlider,
[WidgetType.TEXTAREA]: WidgetTextarea,
[WidgetType.TOGGLESWITCH]: WidgetToggleSwitch,
[WidgetType.CHART]: WidgetChart,
[WidgetType.IMAGE]: WidgetImage,
[WidgetType.IMAGECOMPARE]: WidgetImageCompare,
[WidgetType.GALLERIA]: WidgetGalleria,
[WidgetType.FILEUPLOAD]: WidgetFileUpload,
[WidgetType.TREESELECT]: WidgetTreeSelect
}
/**
* Helper function to get widget component by type
*/
export function getWidgetComponent(type: string): Component | undefined {
return widgetTypeToComponent[type]
}

View File

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

View File

@@ -0,0 +1,27 @@
/**
* Simplified widget interface for Vue-based node rendering
* Removes all DOM manipulation and positioning concerns
*/
export interface SimplifiedWidget<T = any, O = Record<string, any>> {
/** Display name of the widget */
name: string
/** Widget type identifier (e.g., 'STRING', 'INT', 'COMBO') */
type: string
/** Current value of the widget */
value: T
/** Widget options including filtered PrimeVue props */
options?: O
/** Callback fired when value changes */
callback?: (value: T) => void
/** Optional serialization method for custom value handling */
serializeValue?: () => any
/** Optional method to compute widget size requirements */
computeSize?: () => { minHeight: number; maxHeight?: number }
}

View File

@@ -0,0 +1,76 @@
/**
* Widget prop filtering utilities
* Filters out style-related and customization props from PrimeVue components
* to maintain consistent widget appearance across the application
*/
// Props to exclude based on the widget interface specifications
export const STANDARD_EXCLUDED_PROPS = [
'style',
'class',
'dt',
'pt',
'ptOptions',
'unstyled'
] as const
export const INPUT_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
export const PANEL_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'panelClass',
'panelStyle',
'overlayClass'
] as const
export const IMAGE_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'imageClass',
'imageStyle'
] as const
export const GALLERIA_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'thumbnailsPosition',
'verticalThumbnailViewPortHeight',
'indicatorsPosition',
'maskClass',
'containerStyle',
'containerClass',
'galleriaClass'
] as const
export const BADGE_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'badgeClass'
] as const
export const LABEL_EXCLUDED_PROPS = [
...PANEL_EXCLUDED_PROPS,
'labelStyle'
] as const
/**
* Filters widget props by excluding specified properties
* @param props - The props object to filter
* @param excludeList - List of property names to exclude
* @returns Filtered props object
*/
export function filterWidgetProps<T extends Record<string, any>>(
props: T | undefined,
excludeList: readonly string[]
): Partial<T> {
if (!props) return {}
const filtered: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (!excludeList.includes(key)) {
filtered[key] = value
}
}
return filtered as Partial<T>
}

View File

@@ -0,0 +1,287 @@
# Vue Node Component Implementation Plan
## Overview
This plan outlines the implementation of Vue node components that will integrate with the existing LiteGraph system. These components are designed to work within a future transform/sync system while focusing purely on the component architecture.
## Core Components
### 1. **LGraphNode.vue** - Main Node Container
- Receives LGraphNode as prop
- Renders node layout: header, slots, widgets, content
- Positioned absolutely using node.pos
- CSS containment for performance
- Integrates all sub-components
### 2. **NodeHeader.vue** - Node Title & Controls
- Node title display
- Collapse/expand button
- Node color/styling based on type
- Designed for future drag handle integration
### 3. **NodeSlots.vue** - Connection Points Container
- Renders input/output slot visual indicators
- Shows slot names and types
- Visual-only - LiteGraph handles actual connections
- Contains InputSlot and OutputSlot sub-components
- Handles slot layout (vertical/horizontal)
#### 3.1 **InputSlot.vue** - Input Connection Point
- Visual representation of input slots
- Shows connection state
- Click events for future connection logic
- Type-based color coding (mirrors LiteGraph's slot_default_color_by_type)
- Connection dot positioning
- Hover states for connection compatibility
#### 3.2 **OutputSlot.vue** - Output Connection Point
- Visual representation of output slots
- Shows connection state
- Click events for future connection logic
- Type-based color coding (mirrors LiteGraph's slot_default_color_by_type)
- Multiple connection support visualization
- Hover states for connection compatibility
### 4. **NodeWidgets.vue** - Widget Container
- Integrates with existing widget system
- Maps LGraphNode.widgets to Vue widget components
- Uses widget registry for dynamic rendering
- Handles widget layout within node
### 5. **NodeContent.vue** - Custom Content Area
- Extensible area for node-specific content
- Slot-based for future customization
- Allows for specialized node types
### 6. **Node Registry System**
- Maps LGraphNode types to Vue components
- Similar pattern to widget registry
- Enables dynamic node component rendering
- Type-safe component resolution
## Directory Structure
```
src/components/graph/vueNodes/
├── LGraphNode.vue # Main node component
├── NodeHeader.vue # Title/controls
├── NodeSlots.vue # Connection points container
│ ├── InputSlot.vue # Individual input slot
│ └── OutputSlot.vue # Individual output slot
├── NodeWidgets.vue # Widget integration
├── NodeContent.vue # Custom content area
├── nodeRegistry.ts # Node type registry
└── index.ts # Component exports
```
## Key Design Decisions
1. **Use Existing LGraphNode Interface** - No new interfaces needed, work with existing LiteGraph node structure
2. **Pure Component Design** - Components receive LGraphNode as props, emit events up
3. **Widget System Integration** - NodeWidgets.vue leverages existing widget registry
4. **Visual-Only Slots** - Connection logic stays in LiteGraph entirely
5. **Transform-Ready** - Designed to work with absolute positioning in future transform container
6. **Registry Pattern** - Consistent with widget system for dynamic rendering
## Component Props Pattern
Each component follows a consistent prop pattern:
```typescript
// Base props for all node components
interface BaseNodeProps {
node: LGraphNode
readonly?: boolean
}
// Extended props for main node component
interface LGraphNodeProps extends BaseNodeProps {
selected?: boolean // Selection state from graph
executing?: boolean // Execution state
progress?: number // Execution progress (0-1)
error?: string | null // Error state message
zoomLevel?: number // For LOD calculations
}
// Props for slot components
interface SlotProps extends BaseNodeProps {
slot: INodeSlot
index: number
type: 'input' | 'output'
connected?: boolean // Has active connection
compatible?: boolean // For hover states during dragging
}
// Usage
defineProps<LGraphNodeProps>()
```
## Error Handling Pattern
Each component should implement error boundaries for graceful failure:
```vue
<template>
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
Node Render Error
</div>
<div v-else>
<!-- Normal component content -->
</div>
</template>
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const renderError = ref<string | null>(null)
onErrorCaptured((error) => {
renderError.value = error.message
console.error('Vue node component error:', error)
return false // Prevent error propagation
})
</script>
```
This ensures that if any node component encounters an error, it fails gracefully without breaking the entire graph.
## Performance Optimization with v-memo
Vue's `v-memo` directive can prevent unnecessary re-renders by memoizing template sections. This is particularly valuable for nodes with many widgets or during graph manipulation.
### Simple Implementation
Start with basic memoization on the most expensive parts:
```vue
<!-- In LGraphNode.vue -->
<template>
<div class="vue-node">
<!-- Memoize widgets - only re-render when count or values change -->
<NodeWidgets
v-memo="[node.widgets?.length, ...node.widgets?.map(w => w.value) ?? []]"
:node="node"
/>
</div>
</template>
```
### Long-term Implementation
As the system scales, add more granular memoization:
```vue
<template>
<div class="vue-node">
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[node.title, node.color]"
:node="node"
/>
<!-- Slots only update when connections change -->
<NodeSlots
v-memo="[node.inputs?.length, node.outputs?.length]"
:node="node"
/>
<!-- Widgets update on value changes -->
<NodeWidgets
v-memo="[node.widgets?.length, ...node.widgets?.map(w => w.value) ?? []]"
:node="node"
/>
</div>
</template>
```
### Pros
- Prevents widget re-renders during node dragging (position changes don't affect content)
- Scales better with 100+ nodes containing multiple widgets
- Significantly reduces render time for complex node graphs
### Cons
- May miss updates if LiteGraph mutates objects in-place
- Adds memory overhead from cached VDOM
- Can make debugging harder ("why isn't my node updating?")
### Recommendation
Start without v-memo, then add it selectively after profiling identifies performance bottlenecks. The widgets container is the most likely candidate for optimization since widgets are the most complex child components.
## Implementation Strategy
### Phase 1: Core Structure
1. Create LGraphNode.vue base component with layout structure
2. Implement NodeHeader.vue with basic title/controls
3. Build NodeSlots.vue container with visual slot rendering
### Phase 2: Widget Integration
4. Implement NodeWidgets.vue to integrate with existing widget system
5. Create NodeContent.vue for extensibility
### Phase 3: Registry System
6. Build node registry for dynamic component resolution
7. Add index.ts exports
## Visual State Management
Node components will use Tailwind classes dynamically based on state:
```vue
<!-- In LGraphNode.vue -->
<template>
<div :class="[
'absolute border-2 rounded bg-surface-0',
selected ? 'border-primary-500 ring-2 ring-primary-300' : 'border-surface-300',
executing ? 'animate-pulse' : '',
node.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : ''
]">
<!-- Node content -->
</div>
</template>
```
Visual states to support:
- **Selected**: Border color change and ring effect
- **Executing**: Pulse animation
- **Bypassed**: Reduced opacity
- **Error**: Red border and background tint
- **Collapsed**: Height reduction (handled by v-if on body content)
## CSS Performance Strategy
Nodes will use CSS containment for optimal performance:
```css
.lg-node {
position: absolute;
contain: layout style paint;
/* will-change only added during drag via class */
}
.lg-node--dragging {
will-change: transform;
}
```
Key performance considerations:
- Position absolutely within transform pane (no individual transforms)
- CSS containment prevents layout thrashing
- GPU acceleration only during drag operations
- No complex calculations in Vue components
## Design Principles
- **Separation of Concerns**: Each component handles one aspect of node rendering
- **LiteGraph Integration**: Components designed to work with existing LiteGraph data structures
- **Performance First**: CSS containment, efficient rendering patterns
- **Future-Ready**: Architecture supports transform container and event delegation
- **Consistent Patterns**: Follows same patterns as widget system implementation
- **Tailwind-First**: Use utility classes for all styling, no custom CSS
## Expected Outcomes
- Clean, modular Vue components for node rendering
- Seamless integration with existing widget system
- Foundation ready for transform/sync layer implementation
- Maintainable and extensible architecture

View File

@@ -0,0 +1,203 @@
# Vue Widget Implementation Plan
## Overview
This document outlines the implementation plan for creating simplified Vue-based widget components that will work with the new Vue node rendering system.
## Directory Structure
```
src/components/graph/vueWidgets/ # New directory alongside existing widgets
├── WidgetButton.vue
├── WidgetInputText.vue
├── WidgetSelect.vue
├── WidgetColorPicker.vue
├── WidgetMultiSelect.vue
├── WidgetSelectButton.vue
├── WidgetSlider.vue
├── WidgetTextarea.vue
├── WidgetToggleSwitch.vue
├── WidgetChart.vue
├── WidgetImage.vue
├── WidgetImageCompare.vue
├── WidgetGalleria.vue
├── WidgetFileUpload.vue
└── WidgetTreeSelect.vue
```
## Prop Filtering Utility
Create a utility file for prop filtering:
```typescript
// src/utils/widgetPropFilter.ts
// Props to exclude based on the widget interface specifications
export const STANDARD_EXCLUDED_PROPS = ['style', 'class', 'dt', 'pt', 'ptOptions', 'unstyled'] as const
export const INPUT_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'inputClass',
'inputStyle'
] as const
export const PANEL_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'panelClass',
'panelStyle',
'overlayClass'
] as const
export const IMAGE_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'imageClass',
'imageStyle'
] as const
export const GALLERIA_EXCLUDED_PROPS = [
...STANDARD_EXCLUDED_PROPS,
'thumbnailsPosition',
'verticalThumbnailViewPortHeight',
'indicatorsPosition',
'maskClass',
'containerStyle',
'containerClass',
'galleriaClass'
] as const
// Utility function to filter props
export function filterWidgetProps<T extends Record<string, any>>(
props: T | undefined,
excludeList: readonly string[]
): Partial<T> {
if (!props) return {}
const filtered: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (!excludeList.includes(key)) {
filtered[key] = value
}
}
return filtered as Partial<T>
}
```
## Component Template Pattern
Each widget follows this structure without style tags:
```vue
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{ widget.name }}</label>
<ComponentName
v-model="value"
v-bind="filteredProps"
:disabled="readonly"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import ComponentName from 'primevue/componentname'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { filterWidgetProps, STANDARD_EXCLUDED_PROPS } from '@/utils/widgetPropFilter'
const value = defineModel<T>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<T>
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
```
## Complete Widget List (All 15 from the spec)
### Input Components:
1. **WidgetInputText** - Single line text input
2. **WidgetTextarea** - Multiline text input
3. **WidgetSlider** - Numeric range slider
4. **WidgetToggleSwitch** - Boolean on/off switch
### Selection Components:
5. **WidgetSelect** - Dropdown selection
6. **WidgetMultiSelect** - Multiple item selection
7. **WidgetSelectButton** - Button group selection
8. **WidgetTreeSelect** - Hierarchical selection
### Visual Components:
9. **WidgetColorPicker** - Color picker
10. **WidgetImage** - Single image display
11. **WidgetImageCompare** - Before/after image comparison
12. **WidgetGalleria** - Image gallery/carousel
13. **WidgetChart** - Chart display (using Chart.js)
### Action Components:
14. **WidgetButton** - Button with actions
15. **WidgetFileUpload** - File upload interface
## Implementation Details
Each widget will:
- Use `defineModel` for two-way binding
- Import the appropriate PrimeVue component
- Use the prop filtering utility with the correct exclusion list
- Apply minimal Tailwind classes for basic layout
- Handle the `readonly` prop to disable interaction when needed
## Widget Type Mapping
When integrating with the node system, we'll need to map widget types to components:
```typescript
// src/components/graph/vueWidgets/widgetRegistry.ts
export enum WidgetType {
BUTTON = 'BUTTON',
STRING = 'STRING',
INT = 'INT',
FLOAT = 'FLOAT',
NUMBER = 'NUMBER',
BOOLEAN = 'BOOLEAN',
COMBO = 'COMBO',
COLOR = 'COLOR',
MULTISELECT = 'MULTISELECT',
SELECTBUTTON = 'SELECTBUTTON',
SLIDER = 'SLIDER',
TEXTAREA = 'TEXTAREA',
TOGGLESWITCH = 'TOGGLESWITCH',
CHART = 'CHART',
IMAGE = 'IMAGE',
IMAGECOMPARE = 'IMAGECOMPARE',
GALLERIA = 'GALLERIA',
FILEUPLOAD = 'FILEUPLOAD',
TREESELECT = 'TREESELECT'
}
export const widgetTypeToComponent: Record<string, Component> = {
[WidgetType.BUTTON]: WidgetButton,
[WidgetType.STRING]: WidgetInputText,
[WidgetType.INT]: WidgetSlider,
[WidgetType.FLOAT]: WidgetSlider,
[WidgetType.NUMBER]: WidgetSlider, // For compatibility
[WidgetType.BOOLEAN]: WidgetToggleSwitch,
[WidgetType.COMBO]: WidgetSelect,
// ... other mappings
}
```
## SimplifiedWidget Interface
Based on the simplification plan:
```typescript
interface SimplifiedWidget<T = any> {
name: string
type: string
value: T
options?: Record<string, any> // Contains filtered PrimeVue props
callback?: (value: T) => void
}
```
## Key Differences from Current System
- No DOM manipulation or positioning logic
- No visibility/zoom management
- No canvas interaction
- Purely focused on value display and user input
- Relies on parent Vue components for layout and positioning