mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 10:00:08 +00:00
[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:
committed by
Benjamin Lu
parent
d488e59a2a
commit
2ab4fb79ee
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
438
src/components/graph/TransformPane.spec.ts
Normal file
438
src/components/graph/TransformPane.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
133
src/components/graph/TransformPane.vue
Normal file
133
src/components/graph/TransformPane.vue
Normal 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>
|
||||
112
src/components/graph/debug/QuadTreeDebugSection.vue
Normal file
112
src/components/graph/debug/QuadTreeDebugSection.vue
Normal 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>
|
||||
112
src/components/graph/debug/QuadTreeVisualization.vue
Normal file
112
src/components/graph/debug/QuadTreeVisualization.vue
Normal 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>
|
||||
165
src/components/graph/debug/VueNodeDebugPanel.vue
Normal file
165
src/components/graph/debug/VueNodeDebugPanel.vue
Normal 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>
|
||||
84
src/components/graph/vueNodes/InputSlot.vue
Normal file
84
src/components/graph/vueNodes/InputSlot.vue
Normal 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>
|
||||
204
src/components/graph/vueNodes/LGraphNode.vue
Normal file
204
src/components/graph/vueNodes/LGraphNode.vue
Normal 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>
|
||||
42
src/components/graph/vueNodes/NodeContent.vue
Normal file
42
src/components/graph/vueNodes/NodeContent.vue
Normal 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>
|
||||
149
src/components/graph/vueNodes/NodeHeader.vue
Normal file
149
src/components/graph/vueNodes/NodeHeader.vue
Normal 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>
|
||||
140
src/components/graph/vueNodes/NodeSlots.vue
Normal file
140
src/components/graph/vueNodes/NodeSlots.vue
Normal 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>
|
||||
167
src/components/graph/vueNodes/NodeWidgets.vue
Normal file
167
src/components/graph/vueNodes/NodeWidgets.vue
Normal 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>
|
||||
87
src/components/graph/vueNodes/OutputSlot.vue
Normal file
87
src/components/graph/vueNodes/OutputSlot.vue
Normal 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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
95
src/components/graph/vueWidgets/WidgetMarkdown.vue
Normal file
95
src/components/graph/vueWidgets/WidgetMarkdown.vue
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user