[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>
This commit is contained in:
Christian Byrne
2025-08-06 11:51:41 -07:00
committed by Benjamin Lu
parent d488e59a2a
commit 2ab4fb79ee
119 changed files with 12658 additions and 397 deletions

View File

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

View File

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

View File

@@ -16,6 +16,7 @@ import { computed } from 'vue'
import DomWidget from '@/components/graph/widgets/DomWidget.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
@@ -27,6 +28,9 @@ const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
// Skip updating DOM widgets when Vue nodes mode is enabled
if (LiteGraph.vueNodesMode) return
const lowQuality = lgCanvas.low_quality
const currentGraph = lgCanvas.graph

View File

@@ -34,6 +34,54 @@
class="w-full h-full touch-none"
/>
<!-- TransformPane for Vue node rendering (development) -->
<TransformPane
v-if="transformPaneEnabled && canvasStore.canvas && comfyAppReady"
:canvas="canvasStore.canvas as LGraphCanvas"
:viewport="canvasViewport"
:show-debug-overlay="showPerformanceOverlay"
@raf-status-change="rafActive = $event"
@transform-update="handleTransformUpdate"
>
<!-- Vue nodes rendered based on graph nodes -->
<VueGraphNode
v-for="nodeData in nodesToRender"
:key="nodeData.id"
:node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:selected="nodeData.selected"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
: null
"
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
:data-node-id="nodeData.id"
@node-click="handleNodeSelect"
@update:collapsed="handleNodeCollapse"
@update:title="handleNodeTitleUpdate"
/>
</TransformPane>
<!-- Debug Panel (Development Only) -->
<VueNodeDebugPanel
v-model:debug-override-vue-nodes="debugOverrideVueNodes"
v-model:show-performance-overlay="showPerformanceOverlay"
:canvas-viewport="canvasViewport"
:vue-nodes-count="vueNodesCount"
:nodes-in-viewport="nodesInViewport"
:performance-metrics="performanceMetrics"
:current-f-p-s="currentFPS"
:last-transform-time="lastTransformTime"
:raf-active="rafActive"
:is-dev-mode-enabled="isDevModeEnabled"
:should-render-vue-nodes="shouldRenderVueNodes"
:transform-pane-enabled="transformPaneEnabled"
/>
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover />
@@ -50,7 +98,16 @@
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import {
computed,
onMounted,
onUnmounted,
reactive,
ref,
shallowRef,
watch,
watchEffect
} from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
@@ -61,14 +118,24 @@ import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import TransformPane from '@/components/graph/TransformPane.vue'
import VueNodeDebugPanel from '@/components/graph/debug/VueNodeDebugPanel.vue'
import VueGraphNode from '@/components/graph/vueNodes/LGraphNode.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
NodeState,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { useLitegraphSettings } from '@/composables/useLitegraphSettings'
import { useMinimap } from '@/composables/useMinimap'
@@ -77,7 +144,7 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave'
import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence'
import { CORE_SETTINGS } from '@/constants/coreSettings'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -123,6 +190,311 @@ const minimapRef = ref<InstanceType<typeof MiniMap>>()
const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
const minimap = useMinimap()
// Feature flags
const { shouldRenderVueNodes, isDevModeEnabled } = useFeatureFlags()
// TransformPane enabled when Vue nodes are enabled OR debug override
const debugOverrideVueNodes = ref(true) // Default to true for development
const transformPaneEnabled = computed(
() => shouldRenderVueNodes.value || debugOverrideVueNodes.value
)
// Account for browser zoom/DPI scaling
const getActualViewport = () => {
// Get the actual canvas element dimensions which account for zoom
const canvas = canvasRef.value
if (canvas) {
return {
width: canvas.clientWidth,
height: canvas.clientHeight
}
}
// Fallback to window dimensions
return {
width: window.innerWidth,
height: window.innerHeight
}
}
const canvasViewport = ref(getActualViewport())
// Debug metrics - use shallowRef for frequently updating values
const vueNodesCount = shallowRef(0)
const nodesInViewport = shallowRef(0)
const currentFPS = shallowRef(0)
const lastTransformTime = shallowRef(0)
const rafActive = shallowRef(false)
// Rendering options
const showPerformanceOverlay = ref(false)
// FPS tracking
let lastTime = performance.now()
let frameCount = 0
let fpsRafId: number | null = null
const updateFPS = () => {
frameCount++
const currentTime = performance.now()
if (currentTime >= lastTime + 1000) {
currentFPS.value = Math.round(
(frameCount * 1000) / (currentTime - lastTime)
)
frameCount = 0
lastTime = currentTime
}
if (transformPaneEnabled.value) {
fpsRafId = requestAnimationFrame(updateFPS)
}
}
// Start FPS tracking when TransformPane is enabled
watch(transformPaneEnabled, (enabled) => {
if (enabled) {
fpsRafId = requestAnimationFrame(updateFPS)
} else {
// Stop FPS tracking
if (fpsRafId !== null) {
cancelAnimationFrame(fpsRafId)
fpsRafId = null
}
}
})
// Update viewport on resize
useEventListener(window, 'resize', () => {
canvasViewport.value = getActualViewport()
})
// Also update when canvas is ready
watch(canvasRef, () => {
if (canvasRef.value) {
canvasViewport.value = getActualViewport()
}
})
// Vue node lifecycle management - initialize after graph is ready
let nodeManager: ReturnType<typeof useGraphNodeManager> | null = null
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
let detectChangesInRAF = () => {}
const performanceMetrics = reactive({
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
adaptiveQuality: false
})
// Initialize node manager when graph becomes available
// Add a reactivity trigger to force computed re-evaluation
const nodeDataTrigger = ref(0)
const initializeNodeManager = () => {
if (!comfyApp.graph || nodeManager) {
return
}
nodeManager = useGraphNodeManager(comfyApp.graph)
// Use the manager's reactive maps directly
vueNodeData.value = nodeManager.vueNodeData
nodeState.value = nodeManager.nodeState
nodePositions.value = nodeManager.nodePositions
nodeSizes.value = nodeManager.nodeSizes
detectChangesInRAF = nodeManager.detectChangesInRAF
Object.assign(performanceMetrics, nodeManager.performanceMetrics)
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
// Watch for graph availability
watch(
() => comfyApp.graph,
(graph) => {
if (graph) {
initializeNodeManager()
}
},
{ immediate: true }
)
// Transform state for viewport culling
const { syncWithCanvas } = useTransformState()
// Replace problematic computed property with proper reactive system
const nodesToRender = computed(() => {
// Access performanceMetrics to trigger on RAF updates
void performanceMetrics.updateTime
// Access trigger to force re-evaluation after nodeManager initialization
void nodeDataTrigger.value
if (!comfyApp.graph || !transformPaneEnabled.value) {
return []
}
const allNodes = Array.from(vueNodeData.value.values())
// Apply viewport culling - check if node bounds intersect with viewport
if (nodeManager && canvasStore.canvas && comfyApp.canvas) {
const canvas = canvasStore.canvas
const manager = nodeManager
// Ensure transform is synced before checking visibility
syncWithCanvas(comfyApp.canvas)
const ds = canvas.ds
// Access transform time to make this reactive to transform changes
void lastTransformTime.value
// Work in screen space - viewport is simply the canvas element size
const viewport_width = canvas.canvas.width
const viewport_height = canvas.canvas.height
// Add margin that represents a constant distance in canvas space
// Convert canvas units to screen pixels by multiplying by scale
const canvasMarginDistance = 200 // Fixed margin in canvas units
const margin_x = canvasMarginDistance * ds.scale
const margin_y = canvasMarginDistance * ds.scale
const filtered = allNodes.filter((nodeData) => {
const node = manager.getNode(nodeData.id)
if (!node) return false
// Transform node position to screen space (same as DOM widgets)
const screen_x = (node.pos[0] + ds.offset[0]) * ds.scale
const screen_y = (node.pos[1] + ds.offset[1]) * ds.scale
const screen_width = node.size[0] * ds.scale
const screen_height = node.size[1] * ds.scale
// Check if node bounds intersect with expanded viewport (in screen space)
const isVisible = !(
screen_x + screen_width < -margin_x ||
screen_x > viewport_width + margin_x ||
screen_y + screen_height < -margin_y ||
screen_y > viewport_height + margin_y
)
return isVisible
})
return filtered
}
return allNodes
})
// Remove side effects from computed - use watchers instead
watch(
() => vueNodeData.value.size,
(count) => {
vueNodesCount.value = count
},
{ immediate: true }
)
watch(
() => nodesToRender.value.length,
(count) => {
nodesInViewport.value = count
}
)
// Update performance metrics when node counts change
watch(
() => [vueNodeData.value.size, nodesToRender.value.length],
([totalNodes, visibleNodes]) => {
performanceMetrics.nodeCount = totalNodes
performanceMetrics.culledCount = totalNodes - visibleNodes
}
)
// Integrate change detection with TransformPane RAF
// Track previous transform to detect changes
let lastScale = 1
let lastOffsetX = 0
let lastOffsetY = 0
const handleTransformUpdate = (time: number) => {
lastTransformTime.value = time
// Sync transform state only when it changes (avoids reflows)
if (comfyApp.canvas?.ds) {
const currentScale = comfyApp.canvas.ds.scale
const currentOffsetX = comfyApp.canvas.ds.offset[0]
const currentOffsetY = comfyApp.canvas.ds.offset[1]
if (
currentScale !== lastScale ||
currentOffsetX !== lastOffsetX ||
currentOffsetY !== lastOffsetY
) {
syncWithCanvas(comfyApp.canvas)
lastScale = currentScale
lastOffsetX = currentOffsetX
lastOffsetY = currentOffsetY
}
}
// Detect node changes during transform updates
detectChangesInRAF()
// Update performance metrics
performanceMetrics.frameTime = time
void nodesToRender.value.length
}
// Node event handlers
const handleNodeSelect = (event: PointerEvent, nodeData: VueNodeData) => {
if (!canvasStore.canvas || !nodeManager) return
const node = nodeManager.getNode(nodeData.id)
if (!node) return
if (!event.ctrlKey && !event.metaKey) {
canvasStore.canvas.deselectAllNodes()
}
canvasStore.canvas.selectNode(node)
node.selected = true
canvasStore.updateSelectedItems()
}
// Handle node collapse state changes
const handleNodeCollapse = (nodeId: string, collapsed: boolean) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Use LiteGraph's collapse method if the state needs to change
const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) {
node.collapse()
}
}
// Handle node title updates
const handleNodeTitleUpdate = (nodeId: string, newTitle: string) => {
if (!nodeManager) return
const node = nodeManager.getNode(nodeId)
if (!node) return
// Update the node title in LiteGraph for persistence
node.title = newTitle
}
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -274,7 +646,7 @@ const loadCustomNodesI18n = async () => {
i18n.global.mergeLocaleMessage(locale, message)
})
} catch (error) {
console.error('Failed to load custom nodes i18n', error)
// Ignore i18n loading errors - not critical
}
}
@@ -291,6 +663,7 @@ onMounted(async () => {
useCopy()
usePaste()
useWorkflowAutoSave()
useFeatureFlags() // This will automatically sync Vue nodes flag with LiteGraph
comfyApp.vueAppReady = true
@@ -303,9 +676,6 @@ onMounted(async () => {
await settingStore.loadSettingValues()
} catch (error) {
if (error instanceof UnauthorizedError) {
console.log(
'Failed loading user settings, user unauthorized, cleaning local Comfy.userId'
)
localStorage.removeItem('Comfy.userId')
localStorage.removeItem('Comfy.userName')
window.location.reload()
@@ -330,6 +700,11 @@ onMounted(async () => {
comfyAppReady.value = true
// Initialize node manager after setup is complete
if (comfyApp.graph) {
initializeNodeManager()
}
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,
() => canvasStore.updateSelectedItems()
@@ -377,4 +752,18 @@ onMounted(async () => {
emit('ready')
})
onUnmounted(() => {
// Clean up FPS tracking
if (fpsRafId !== null) {
cancelAnimationFrame(fpsRafId)
fpsRafId = null
}
// Clean up node manager
if (nodeManager) {
nodeManager.cleanup()
nodeManager = null
}
})
</script>

View File

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

View File

@@ -0,0 +1,133 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div
class="transform-pane"
:class="{ 'transform-pane--interacting': isInteracting }"
:style="transformStyle"
@pointerdown="handlePointerDown"
>
<!-- Vue nodes will be rendered here -->
<slot />
<!-- DEV ONLY: Viewport bounds visualization -->
<div
v-if="props.showDebugOverlay"
class="viewport-debug-overlay"
:style="{
position: 'absolute',
left: '10px',
top: '10px',
border: '2px solid red',
width: (props.viewport?.width || 0) - 20 + 'px',
height: (props.viewport?.height || 0) - 20 + 'px',
pointerEvents: 'none',
opacity: 0.5
}"
>
<div
style="
position: absolute;
top: 0;
left: 0;
background: red;
color: white;
padding: 2px 5px;
font-size: 10px;
"
>
Viewport: {{ props.viewport?.width }}x{{ props.viewport?.height }} DPR:
{{ devicePixelRatio }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useTransformState } from '@/composables/element/useTransformState'
import { useCanvasTransformSync } from '@/composables/graph/useCanvasTransformSync'
import { useTransformSettling } from '@/composables/graph/useTransformSettling'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface TransformPaneProps {
canvas?: LGraphCanvas
viewport?: { width: number; height: number }
showDebugOverlay?: boolean
}
const props = defineProps<TransformPaneProps>()
// Get device pixel ratio for display
const devicePixelRatio = window.devicePixelRatio || 1
// Transform state management
const {
camera,
transformStyle,
syncWithCanvas,
canvasToScreen,
screenToCanvas,
isNodeInViewport
} = useTransformState()
// Transform settling detection for re-rasterization optimization
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming } = useTransformSettling(canvasElement, {
settleDelay: 200,
trackPan: true
})
// Use isTransforming for the CSS class (aliased for clarity)
const isInteracting = isTransforming
// Provide transform utilities to child components
provide('transformState', {
camera,
canvasToScreen,
screenToCanvas,
isNodeInViewport
})
// Event delegation for node interactions
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as HTMLElement
const nodeElement = target.closest('[data-node-id]')
if (nodeElement) {
// TODO: Emit event for node interaction
// Node interaction with nodeId will be handled in future implementation
}
}
// Canvas transform synchronization
const emit = defineEmits<{
rafStatusChange: [active: boolean]
transformUpdate: [time: number]
}>()
useCanvasTransformSync(props.canvas, syncWithCanvas, {
onStart: () => emit('rafStatusChange', true),
onUpdate: (duration) => emit('transformUpdate', duration),
onStop: () => emit('rafStatusChange', false)
})
</script>
<style scoped>
.transform-pane {
position: absolute;
inset: 0;
contain: layout style paint;
transform-origin: 0 0;
pointer-events: none;
}
.transform-pane--interacting {
will-change: transform;
}
/* Allow pointer events on nodes */
.transform-pane :deep([data-node-id]) {
pointer-events: auto;
}
</style>

View File

@@ -0,0 +1,112 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<div class="pt-2 border-t border-surface-200 dark-theme:border-surface-700">
<h4 class="font-semibold mb-1">QuadTree Spatial Index</h4>
<!-- Enable/Disable Toggle -->
<div class="mb-2">
<label class="flex items-center gap-2">
<input
:checked="enabled"
type="checkbox"
@change="$emit('toggle', ($event.target as HTMLInputElement).checked)"
/>
<span>Enable Spatial Indexing</span>
</label>
</div>
<!-- Status Message -->
<p v-if="!enabled" class="text-muted text-xs italic">
{{ statusMessage }}
</p>
<!-- Metrics when enabled -->
<template v-if="enabled && metrics">
<p class="text-muted">Strategy: {{ strategy }}</p>
<p class="text-muted">Total Nodes: {{ metrics.totalNodes }}</p>
<p class="text-muted">Visible Nodes: {{ metrics.visibleNodes }}</p>
<p class="text-muted">Query Time: {{ metrics.queryTime.toFixed(2) }}ms</p>
<p class="text-muted">Tree Depth: {{ metrics.treeDepth }}</p>
<p class="text-muted">Culling Efficiency: {{ cullingEfficiency }}</p>
<p class="text-muted">Rebuilds: {{ metrics.rebuildCount }}</p>
<!-- Show debug visualization toggle -->
<div class="mt-2">
<label class="flex items-center gap-2">
<input
:checked="showVisualization"
type="checkbox"
@change="
$emit(
'toggle-visualization',
($event.target as HTMLInputElement).checked
)
"
/>
<span>Show QuadTree Boundaries</span>
</label>
</div>
</template>
<!-- Performance Comparison -->
<template v-if="enabled && performanceComparison">
<div class="mt-2 text-xs">
<p class="text-muted font-semibold">Performance vs Linear:</p>
<p class="text-muted">Speedup: {{ performanceComparison.speedup }}x</p>
<p class="text-muted">
Break-even: ~{{ performanceComparison.breakEvenNodeCount }} nodes
</p>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
enabled: boolean
metrics?: {
totalNodes: number
visibleNodes: number
queryTime: number
treeDepth: number
rebuildCount: number
}
strategy?: string
threshold?: number
showVisualization?: boolean
performanceComparison?: {
speedup: number
breakEvenNodeCount: number
}
}
const props = withDefaults(defineProps<Props>(), {
metrics: undefined,
strategy: 'quadtree',
threshold: 100,
showVisualization: false,
performanceComparison: undefined
})
defineEmits<{
toggle: [enabled: boolean]
'toggle-visualization': [show: boolean]
}>()
const statusMessage = computed(() => {
if (!props.enabled && props.metrics) {
return `Disabled (threshold: ${props.threshold} nodes, current: ${props.metrics.totalNodes})`
}
return `Spatial indexing will enable at ${props.threshold}+ nodes`
})
const cullingEfficiency = computed(() => {
if (!props.metrics || props.metrics.totalNodes === 0) return 'N/A'
const culled = props.metrics.totalNodes - props.metrics.visibleNodes
const percentage = ((culled / props.metrics.totalNodes) * 100).toFixed(1)
return `${culled} nodes (${percentage}%)`
})
</script>

View File

@@ -0,0 +1,112 @@
<template>
<svg
v-if="visible && debugInfo"
:width="svgSize.width"
:height="svgSize.height"
:style="svgStyle"
class="quadtree-visualization"
>
<!-- QuadTree boundaries -->
<g v-for="(node, index) in flattenedNodes" :key="`quad-${index}`">
<rect
:x="node.bounds.x"
:y="node.bounds.y"
:width="node.bounds.width"
:height="node.bounds.height"
:stroke="getDepthColor(node.depth)"
:stroke-width="getStrokeWidth(node.depth)"
fill="none"
:opacity="0.3 + node.depth * 0.05"
/>
</g>
<!-- Viewport bounds (optional) -->
<rect
v-if="viewportBounds"
:x="viewportBounds.x"
:y="viewportBounds.y"
:width="viewportBounds.width"
:height="viewportBounds.height"
stroke="#00ff00"
stroke-width="3"
fill="none"
stroke-dasharray="10,5"
opacity="0.8"
/>
</svg>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Bounds } from '@/utils/spatial/QuadTree'
interface Props {
visible: boolean
debugInfo: any | null
transformStyle: any
viewportBounds?: Bounds
}
const props = defineProps<Props>()
// Flatten the tree structure for rendering
const flattenedNodes = computed(() => {
if (!props.debugInfo?.tree) return []
const nodes: any[] = []
const traverse = (node: any, depth = 0) => {
nodes.push({
bounds: node.bounds,
depth,
itemCount: node.itemCount,
divided: node.divided
})
if (node.children) {
node.children.forEach((child: any) => traverse(child, depth + 1))
}
}
traverse(props.debugInfo.tree)
return nodes
})
// SVG size (matches the transform pane size)
const svgSize = ref({ width: 20000, height: 20000 })
// Apply the same transform as the TransformPane
const svgStyle = computed(() => ({
...props.transformStyle,
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none'
}))
// Color based on depth
const getDepthColor = (depth: number): string => {
const colors = [
'#ff6b6b', // Red
'#ffa500', // Orange
'#ffd93d', // Yellow
'#6bcf7f', // Green
'#4da6ff', // Blue
'#a78bfa' // Purple
]
return colors[depth % colors.length]
}
// Stroke width based on depth
const getStrokeWidth = (depth: number): number => {
return Math.max(0.5, 2 - depth * 0.3)
}
</script>
<style scoped>
.quadtree-visualization {
position: absolute;
overflow: visible;
z-index: 10; /* Above nodes but below UI */
}
</style>

View File

@@ -0,0 +1,165 @@
<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
<template>
<!-- TransformPane Debug Controls -->
<div
class="fixed top-20 right-4 bg-surface-0 dark-theme:bg-surface-800 p-4 rounded-lg shadow-lg border border-surface-300 dark-theme:border-surface-600 z-50 pointer-events-auto w-80"
style="contain: layout style"
>
<h3 class="font-bold mb-2 text-sm">TransformPane Debug</h3>
<div class="space-y-2 text-xs">
<div>
<label class="flex items-center gap-2">
<input v-model="debugOverrideVueNodes" type="checkbox" />
<span>Enable TransformPane</span>
</label>
</div>
<!-- Canvas Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Canvas State</h4>
<p class="text-muted">
Status: {{ canvasStore.canvas ? 'Ready' : 'Not Ready' }}
</p>
<p class="text-muted">
Viewport: {{ Math.round(canvasViewport.width) }}x{{
Math.round(canvasViewport.height)
}}
</p>
<template v-if="canvasStore.canvas?.ds">
<p class="text-muted">
Offset: ({{ Math.round(canvasStore.canvas.ds.offset[0]) }},
{{ Math.round(canvasStore.canvas.ds.offset[1]) }})
</p>
<p class="text-muted">
Scale: {{ canvasStore.canvas.ds.scale?.toFixed(3) || 1 }}
</p>
</template>
</div>
<!-- Node Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Graph Metrics</h4>
<p class="text-muted">
Total Nodes: {{ comfyApp.graph?.nodes?.length || 0 }}
</p>
<p class="text-muted">
Selected Nodes: {{ canvasStore.canvas?.selectedItems?.size || 0 }}
</p>
<p class="text-muted">Vue Nodes Rendered: {{ vueNodesCount }}</p>
<p class="text-muted">Nodes in Viewport: {{ nodesInViewport }}</p>
<p class="text-muted">
Culled Nodes: {{ performanceMetrics.culledCount }}
</p>
<p class="text-muted">
Cull Percentage:
{{
Math.round(
((vueNodesCount - nodesInViewport) / Math.max(vueNodesCount, 1)) *
100
)
}}%
</p>
</div>
<!-- Performance Metrics -->
<div
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Performance</h4>
<p v-memo="[currentFPS]" class="text-muted">FPS: {{ currentFPS }}</p>
<p v-memo="[Math.round(lastTransformTime)]" class="text-muted">
Transform Update: {{ Math.round(lastTransformTime) }}ms
</p>
<p
v-memo="[Math.round(performanceMetrics.updateTime)]"
class="text-muted"
>
Lifecycle Update: {{ Math.round(performanceMetrics.updateTime) }}ms
</p>
<p v-memo="[rafActive]" class="text-muted">
RAF Active: {{ rafActive ? 'Yes' : 'No' }}
</p>
<p v-memo="[performanceMetrics.adaptiveQuality]" class="text-muted">
Adaptive Quality:
{{ performanceMetrics.adaptiveQuality ? 'On' : 'Off' }}
</p>
</div>
<!-- Feature Flags Status -->
<div
v-if="isDevModeEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Feature Flags</h4>
<p class="text-muted text-xs">
Vue Nodes: {{ shouldRenderVueNodes ? 'Enabled' : 'Disabled' }}
</p>
<p class="text-muted text-xs">
Dev Mode: {{ isDevModeEnabled ? 'Enabled' : 'Disabled' }}
</p>
</div>
<!-- Performance Options -->
<div
v-if="transformPaneEnabled"
class="pt-2 border-t border-surface-200 dark-theme:border-surface-700"
>
<h4 class="font-semibold mb-1">Debug Options</h4>
<label class="flex items-center gap-2">
<input v-model="showPerformanceOverlay" type="checkbox" />
<span>Show Performance Overlay</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
interface Props {
debugOverrideVueNodes: boolean
canvasViewport: { width: number; height: number }
vueNodesCount: number
nodesInViewport: number
performanceMetrics: {
culledCount: number
updateTime: number
adaptiveQuality: boolean
}
currentFPS: number
lastTransformTime: number
rafActive: boolean
isDevModeEnabled: boolean
shouldRenderVueNodes: boolean
transformPaneEnabled: boolean
showPerformanceOverlay: boolean
}
interface Emits {
(e: 'update:debugOverrideVueNodes', value: boolean): void
(e: 'update:showPerformanceOverlay', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const canvasStore = useCanvasStore()
const debugOverrideVueNodes = computed({
get: () => props.debugOverrideVueNodes,
set: (value: boolean) => emit('update:debugOverrideVueNodes', value)
})
const showPerformanceOverlay = computed({
get: () => props.showPerformanceOverlay,
set: (value: boolean) => emit('update:showPerformanceOverlay', value)
})
</script>

View File

@@ -0,0 +1,84 @@
<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"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pr-2 hover:bg-black/5': !dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
@pointerdown="handleClick"
>
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
/>
</div>
<!-- Slot Name -->
<span v-if="!dotOnly" class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Input ${index}` }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { getSlotColor } from '@/constants/slotColors'
import {
COMFY_VUE_NODE_DIMENSIONS,
INodeSlot,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
interface InputSlotProps {
node?: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<InputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// 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
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<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-lg',
'contain-layout contain-style contain-paint',
selected ? 'border-blue-500 ring-2 ring-blue-300' : 'border-gray-600',
executing ? 'animate-pulse' : '',
nodeData.mode === 4 ? 'opacity-50' : '', // bypassed
error ? 'border-red-500 bg-red-50' : '',
isDragging ? 'will-change-transform' : '',
lodCssClass
]"
:style="{
transform: `translate(${position?.x ?? 0}px, ${(position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
width: size ? `${size.width}px` : '200px',
height: size ? `${size.height}px` : 'auto',
backgroundColor: '#353535'
}"
@pointerdown="handlePointerDown"
>
<!-- 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"
/>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
v-if="!isMinimalLOD && !isCollapsed"
class="flex flex-col gap-2"
: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"
/>
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets
v-if="shouldRenderWidgets && nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length, lodLevel]"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
<!-- Custom content at reduced+ detail -->
<NodeContent
v-if="shouldRenderContent && hasCustomContent"
:node-data="nodeData"
:readonly="readonly"
:lod-level="lodLevel"
/>
</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 { computed, onErrorCaptured, ref, toRef, watch } from 'vue'
// Import the VueNodeData type
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { LODLevel, useLOD } from '@/composables/graph/useLOD'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '../../../lib/litegraph/src/litegraph'
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 {
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
})
// Track dragging state for will-change optimization
const isDragging = ref(false)
// 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
})
// Event handlers
const handlePointerDown = (event: PointerEvent) => {
if (!props.nodeData) {
console.warn('LGraphNode: nodeData is null/undefined in handlePointerDown')
return
}
emit('node-click', event, props.nodeData)
}
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)
}
// Expose methods for parent to control dragging state
defineExpose({
setDragging(dragging: boolean) {
isDragging.value = dragging
}
})
</script>

View File

@@ -0,0 +1,42 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<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 { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LODLevel } from '@/composables/graph/useLOD'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '../../../lib/litegraph/src/litegraph'
interface NodeContentProps {
node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure
readonly?: boolean
lodLevel?: LODLevel
}
defineProps<NodeContentProps>()
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,149 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<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 p-2 rounded-t-lg cursor-move"
:data-testid="`node-header-${nodeInfo?.id || ''}`"
:style="{
backgroundColor: headerColor,
color: textColor
}"
@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]"
></i>
</button>
<!-- Node Title -->
<div class="text-sm font-medium 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 type { LODLevel } from '@/composables/graph/useLOD'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '../../../lib/litegraph/src/litegraph'
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
}
}
)
// Compute header color based on node color property or type
const headerColor = computed(() => {
const info = nodeInfo.value
if (!info) return '#353535'
if (info.mode === 4) return '#666' // Bypassed
if (info.mode === 2) return '#444' // Muted
return '#353535' // Default
})
// Compute text color for contrast
const textColor = computed(() => {
const color = headerColor.value
if (!color || color === '#353535' || color === '#444' || color === '#666') {
return '#fff'
}
const colorStr = String(color)
const rgb = parseInt(
colorStr.startsWith('#') ? colorStr.slice(1) : colorStr,
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) {
isEditing.value = true
}
}
const handleTitleEdit = (newTitle: string) => {
isEditing.value = false
const trimmedTitle = newTitle.trim()
if (trimmedTitle && trimmedTitle !== displayTitle.value) {
// Emit for litegraph sync
emit('update:title', trimmedTitle)
}
}
const handleTitleCancel = () => {
isEditing.value = false
// Reset displayTitle to the current node title
displayTitle.value = nodeInfo.value?.title || 'Untitled'
}
</script>

View File

@@ -0,0 +1,140 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<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 flex justify-between">
<div v-if="filteredInputs.length" class="flex flex-col">
<InputSlot
v-for="(input, index) in filteredInputs"
:key="`input-${index}`"
:slot-data="input"
:index="getActualInputIndex(input, index)"
:readonly="readonly"
@slot-click="
handleInputSlotClick(getActualInputIndex(input, index), $event)
"
/>
</div>
<div v-if="filteredOutputs.length" class="flex flex-col ml-auto">
<OutputSlot
v-for="(output, index) in filteredOutputs"
:key="`output-${index}`"
:slot-data="output"
:index="index"
:readonly="readonly"
@slot-click="handleOutputSlotClick(index, $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, onUnmounted, ref } from 'vue'
import { useEventForwarding } from '@/composables/graph/useEventForwarding'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LODLevel } from '@/composables/graph/useLOD'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { isSlotObject } from '@/utils/typeGuardUtil'
import type {
INodeSlot,
LGraphNode
} from '../../../lib/litegraph/src/litegraph'
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)
// 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
}
// Set up event forwarding for slot interactions
const { handleSlotPointerDown, cleanup } = useEventForwarding()
// Handle input slot click
const handleInputSlotClick = (_index: number, event: PointerEvent) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Handle output slot click
const handleOutputSlotClick = (_index: number, event: PointerEvent) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Clean up event listeners on unmount
onUnmounted(() => {
cleanup()
})
// Error boundary implementation
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
onErrorCaptured((error) => {
renderError.value = error.message
toastErrorHandler(error)
return false
})
</script>

View File

@@ -0,0 +1,167 @@
<template>
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<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 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]
}"
:index="index"
:readonly="readonly"
:dot-only="true"
@slot-click="handleWidgetSlotClick($event, widget)"
/>
</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, onUnmounted, ref } from 'vue'
// Import widget components directly
import WidgetInputText from '@/components/graph/vueWidgets/WidgetInputText.vue'
import { widgetTypeToComponent } from '@/components/graph/vueWidgets/widgetRegistry'
import { useEventForwarding } from '@/composables/graph/useEventForwarding'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { LODLevel } from '@/composables/graph/useLOD'
import {
ESSENTIAL_WIDGET_TYPES,
useWidgetRenderer
} from '@/composables/graph/useWidgetRenderer'
import { useErrorHandling } from '@/composables/useErrorHandling'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { LGraphNode } from '../../../lib/litegraph/src/litegraph'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
node?: LGraphNode
nodeData?: VueNodeData
readonly?: boolean
lodLevel?: LODLevel
}
const props = defineProps<NodeWidgetsProps>()
// Set up event forwarding for slot interactions
const { handleSlotPointerDown, cleanup } = useEventForwarding()
// Use widget renderer composable
const { getWidgetComponent, shouldRenderAsVue } = useWidgetRenderer()
// 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 &&
!ESSENTIAL_WIDGET_TYPES.has(widget.type)
)
continue
const componentName = getWidgetComponent(widget.type)
const vueComponent = widgetTypeToComponent[componentName] || 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
})
// Handle widget slot click
const handleWidgetSlotClick = (
event: PointerEvent,
_widget: ProcessedWidget
) => {
// Forward the event to LiteGraph for native slot handling
handleSlotPointerDown(event)
}
// Clean up event listeners on unmount
onUnmounted(() => {
cleanup()
})
</script>

View File

@@ -0,0 +1,87 @@
<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"
:class="{
'opacity-70': readonly,
'lg-slot--connected': connected,
'lg-slot--compatible': compatible,
'lg-slot--dot-only': dotOnly,
'pl-2 hover:bg-black/5': !dotOnly,
'justify-center': dotOnly
}"
:style="{
height: slotHeight + 'px'
}"
@pointerdown="handleClick"
>
<!-- Slot Name -->
<span v-if="!dotOnly" class="text-xs text-surface-700 whitespace-nowrap">
{{ slotData.name || `Output ${index}` }}
</span>
<!-- Connection Dot -->
<div class="w-5 h-5 flex items-center justify-center group/slot">
<div
class="w-2 h-2 rounded-full bg-white transition-all duration-150 group-hover/slot:w-2.5 group-hover/slot:h-2.5 group-hover/slot:border-2 group-hover/slot:border-white"
:style="{
backgroundColor: slotColor
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onErrorCaptured, ref } 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'
interface OutputSlotProps {
node?: LGraphNode
slotData: INodeSlot
index: number
connected?: boolean
compatible?: boolean
readonly?: boolean
dotOnly?: boolean
}
const props = defineProps<OutputSlotProps>()
const emit = defineEmits<{
'slot-click': [event: PointerEvent]
}>()
// 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
// Handle click events
const handleClick = (event: PointerEvent) => {
if (!props.readonly) {
emit('slot-click', event)
}
}
</script>

View File

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

View File

@@ -3,7 +3,12 @@
<label v-if="widget.name" class="text-sm opacity-80">{{
widget.name
}}</label>
<Button v-bind="filteredProps" :disabled="readonly" @click="handleClick" />
<Button
v-bind="filteredProps"
:disabled="readonly"
size="small"
@click="handleClick"
/>
</div>
</template>

View File

@@ -1,65 +1,78 @@
<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
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'
interface ChartData {
title?: string
data: Array<{
label: string
value: number
}>
}
type ChartWidgetOptions = NonNullable<ChartInputSpec['options']>
const value = defineModel<ChartData>({ required: true })
defineProps<{
widget: SimplifiedWidget<ChartData>
const props = defineProps<{
widget: SimplifiedWidget<ChartData, ChartWidgetOptions>
readonly?: boolean
}>()
const maxValue = computed(() => {
if (!value.value?.data?.length) return 1
return Math.max(...value.value.data.map((item) => item.value))
})
const chartType = computed(() => props.widget.options?.type ?? 'line')
const chartData = computed(() => value.value || { labels: [], datasets: [] })
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: '#FFF',
usePointStyle: true,
pointStyle: 'circle'
}
}
},
scales: {
x: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: true,
color: '#9FA2BD',
drawTicks: false,
drawOnChartArea: true,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
},
y: {
ticks: {
color: '#9FA2BD'
},
grid: {
display: false,
drawTicks: false,
drawOnChartArea: false,
drawBorder: false
},
border: {
display: true,
color: '#9FA2BD'
}
}
}
}))
</script>

View File

@@ -1,9 +1,16 @@
<!-- Needs custom color picker for alpha support -->
<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" />
<ColorPicker
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
inline
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,19 +18,31 @@
import ColorPicker from 'primevue/colorpicker'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
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>
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

View File

@@ -1,66 +1,324 @@
<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"
/>
<!-- 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">
<!-- Quick and dirty file type detection for testing -->
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
<span class="text-xs opacity-60"> Drop your file or </span>
<div>
<Button
label="Browse Files"
size="small"
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 FileUpload from 'primevue/fileupload'
import { computed } from 'vue'
import Button from 'primevue/button'
import Select from 'primevue/select'
import { computed, onUnmounted, ref, watch } from 'vue'
// import { useI18n } from 'vue-i18n' // Commented out for testing
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
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 { t } = useI18n() // Commented out for testing
const props = defineProps<{
widget: SimplifiedWidget<File[] | null>
modelValue: File[] | null
readonly?: boolean
}>()
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
const emit = defineEmits<{
'update:modelValue': [value: File[] | null]
}>()
const handleUpload = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
// Use the composable for consistent widget value handling
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 handleSelect = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
const clearFile = () => {
// Clear the file
onChange(null)
// Reset file input
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
const handleRemove = (event: any) => {
if (!props.readonly && props.widget.callback) {
props.widget.callback(event.files)
}
const handleEdit = () => {
// TODO: hook up with maskeditor
}
const handleClear = () => {
if (!props.readonly && props.widget.callback) {
props.widget.callback([])
// Clear file input when value is cleared externally
watch(localValue, (newValue) => {
if (!newValue || newValue.length === 0) {
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
}
})
const handleError = (event: any) => {
// Could be extended to handle error reporting
console.warn('File upload error:', event)
}
// Clean up image URL on unmount
onUnmounted(() => {
if (imageUrl.value) {
URL.revokeObjectURL(imageUrl.value)
}
})
</script>

View File

@@ -1,17 +1,27 @@
<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"
:pt="{
thumbnails: {
class: 'overflow-hidden'
},
thumbnailContent: {
class: 'py-4 px-2'
},
thumbnailPrevButton: {
class: 'm-0'
},
thumbnailNextButton: {
class: 'm-0'
}
}"
>
<template #item="{ item }">
<img
@@ -21,11 +31,13 @@
/>
</template>
<template #thumbnail="{ item }">
<img
:src="item.thumbnailImageSrc || item.src || item"
:alt="item.alt || 'Gallery thumbnail'"
class="w-16 h-16 object-cover"
/>
<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>
@@ -85,13 +97,6 @@ const showThumbnails = computed(() => {
)
})
const showIndicators = computed(() => {
return (
props.widget.options?.showIndicators !== false &&
galleryImages.value.length > 1
)
})
const showNavButtons = computed(() => {
return (
props.widget.options?.showNavButtons !== false &&
@@ -99,3 +104,20 @@ const showNavButtons = computed(() => {
)
})
</script>
<style scoped>
/* Ensure thumbnail container doesn't overflow */
:deep(.p-galleria-thumbnails) {
overflow: hidden;
}
/* Constrain thumbnail items to prevent overlap */
:deep(.p-galleria-thumbnail-item) {
flex-shrink: 0;
}
/* Ensure thumbnail wrapper maintains aspect ratio */
:deep(.p-galleria-thumbnail) {
overflow: hidden;
}
</style>

View File

@@ -1,172 +1,70 @@
<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>
<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 Image from 'primevue/image'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import ImageCompare from 'primevue/imagecompare'
import { computed } 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>
widget: SimplifiedWidget<ImageCompareValue | string>
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
return typeof value === 'string' ? value : value?.before || ''
})
const afterImage = computed(() => {
const value = props.widget.value
return typeof value === 'string' ? value : value?.after
return typeof value === 'string' ? '' : 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 beforeAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.beforeAlt
? value.beforeAlt
: 'Before image'
})
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
const afterAlt = computed(() => {
const value = props.widget.value
return typeof value === 'object' && value?.afterAlt
? value.afterAlt
: 'After image'
})
</script>

View File

@@ -1,9 +1,16 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<InputText v-model="value" v-bind="filteredProps" :disabled="readonly" />
<InputText
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,19 +18,30 @@
import InputText from 'primevue/inputtext'
import { computed } from 'vue'
import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
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>
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)
)

View File

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

View File

@@ -1,9 +1,19 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<MultiSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
<MultiSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,19 +21,31 @@
import MultiSelect from 'primevue/multiselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
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[]>
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,

View File

@@ -1,9 +1,23 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div
class="flex items-center justify-between gap-4"
:style="{ height: widgetHeight + 'px' }"
>
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<Select v-model="value" v-bind="filteredProps" :disabled="readonly" />
<Select
v-model="localValue"
:options="selectOptions"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
:pt="{
option: 'text-xs'
}"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,20 +25,48 @@
import Select from 'primevue/select'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import {
PANEL_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<any>({ required: true })
import { COMFY_VUE_NODE_DIMENSIONS } from '../../../lib/litegraph/src/litegraph'
const props = defineProps<{
widget: SimplifiedWidget<any>
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
})
// Get widget height from litegraph constants
const widgetHeight = COMFY_VUE_NODE_DIMENSIONS.components.STANDARD_WIDGET_HEIGHT
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
// Extract select options from widget options
const selectOptions = computed(() => {
const options = props.widget.options
if (options?.values && Array.isArray(options.values)) {
return options.values
}
return []
})
</script>

View File

@@ -1,9 +1,20 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<SelectButton v-model="value" v-bind="filteredProps" :disabled="readonly" />
<SelectButton
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
:pt="{
pcToggleButton: {
label: 'text-xs'
}
}"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,20 +22,42 @@
import SelectButton from 'primevue/selectbutton'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
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>
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
})
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-selectbutton) {
border: 1px solid transparent;
}
:deep(.p-selectbutton:hover) {
border-color: currentColor;
}
</style>

View File

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

View File

@@ -1,29 +1,43 @@
<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>
<Textarea
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="w-full text-xs"
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 {
INPUT_EXCLUDED_PROPS,
filterWidgetProps
} from '@/utils/widgetPropFilter'
const value = defineModel<string>({ required: true })
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)
)

View File

@@ -1,9 +1,14 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<ToggleSwitch v-model="value" v-bind="filteredProps" :disabled="readonly" />
<ToggleSwitch
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,20 +16,41 @@
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'
const value = defineModel<boolean>({ required: true })
const props = defineProps<{
widget: SimplifiedWidget<boolean>
modelValue: boolean
readonly?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
}>()
// Use the composable for consistent widget value handling
const { localValue, onChange } = useBooleanWidgetValue(
props.widget,
props.modelValue,
emit
)
const filteredProps = computed(() =>
filterWidgetProps(props.widget.options, STANDARD_EXCLUDED_PROPS)
)
</script>
<style scoped>
:deep(.p-toggleswitch .p-toggleswitch-slider) {
border: 1px solid transparent;
}
:deep(.p-toggleswitch:hover .p-toggleswitch-slider) {
border-color: currentColor;
}
</style>

View File

@@ -1,9 +1,16 @@
<template>
<div class="flex flex-col gap-1">
<label v-if="widget.name" class="text-sm opacity-80">{{
<div class="flex items-center justify-between gap-4">
<label v-if="widget.name" class="text-xs opacity-80 min-w-[4em] truncate">{{
widget.name
}}</label>
<TreeSelect v-model="value" v-bind="filteredProps" :disabled="readonly" />
<TreeSelect
v-model="localValue"
v-bind="filteredProps"
:disabled="readonly"
class="flex-grow min-w-[8em] max-w-[20em] text-xs"
size="small"
@update:model-value="onChange"
/>
</div>
</template>
@@ -11,19 +18,31 @@
import TreeSelect from 'primevue/treeselect'
import { computed } from 'vue'
import { useWidgetValue } from '@/composables/graph/useWidgetValue'
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>
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,

View File

@@ -12,6 +12,7 @@ import WidgetGalleria from './WidgetGalleria.vue'
import WidgetImage from './WidgetImage.vue'
import WidgetImageCompare from './WidgetImageCompare.vue'
import WidgetInputText from './WidgetInputText.vue'
import WidgetMarkdown from './WidgetMarkdown.vue'
import WidgetMultiSelect from './WidgetMultiSelect.vue'
import WidgetSelect from './WidgetSelect.vue'
import WidgetSelectButton from './WidgetSelectButton.vue'
@@ -42,7 +43,8 @@ export enum WidgetType {
IMAGECOMPARE = 'IMAGECOMPARE',
GALLERIA = 'GALLERIA',
FILEUPLOAD = 'FILEUPLOAD',
TREESELECT = 'TREESELECT'
TREESELECT = 'TREESELECT',
MARKDOWN = 'MARKDOWN'
}
/**
@@ -69,7 +71,8 @@ export const widgetTypeToComponent: Record<string, Component> = {
[WidgetType.IMAGECOMPARE]: WidgetImageCompare,
[WidgetType.GALLERIA]: WidgetGalleria,
[WidgetType.FILEUPLOAD]: WidgetFileUpload,
[WidgetType.TREESELECT]: WidgetTreeSelect
[WidgetType.TREESELECT]: WidgetTreeSelect,
[WidgetType.MARKDOWN]: WidgetMarkdown
}
/**