mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-05 15:40:10 +00:00
[feat] Implement Vue-based node rendering components
- LGraphNode: Main container with transform-based positioning - NodeHeader: Collapsible title bar with dynamic coloring - NodeSlots: Input/output connection visualization - NodeWidgets: Integration with existing widget system - NodeContent: Extensibility placeholder - Error boundaries and performance optimizations (v-memo, CSS containment)
This commit is contained in:
68
src/components/graph/vueNodes/InputSlot.vue
Normal file
68
src/components/graph/vueNodes/InputSlot.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--input flex items-center gap-2 py-1 pl-2 pr-4 cursor-crosshair hover:bg-black/5"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible
|
||||
}"
|
||||
@pointerdown="handleClick"
|
||||
>
|
||||
<!-- Connection Dot -->
|
||||
<div
|
||||
class="lg-slot__dot w-3 h-3 rounded-full border-2"
|
||||
:style="{
|
||||
backgroundColor: connected ? slotColor : 'transparent',
|
||||
borderColor: slotColor
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<span class="text-xs text-surface-700 whitespace-nowrap">
|
||||
{{ slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
|
||||
interface InputSlotProps {
|
||||
node: LGraphNode
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<InputSlotProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue input slot error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (event: PointerEvent) => {
|
||||
if (!props.readonly) {
|
||||
emit('slot-click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
168
src/components/graph/vueNodes/LGraphNode.vue
Normal file
168
src/components/graph/vueNodes/LGraphNode.vue
Normal file
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Render Error
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
:class="[
|
||||
'lg-node absolute border-2 rounded bg-surface-0',
|
||||
'contain-layout contain-style contain-paint',
|
||||
selected
|
||||
? 'border-primary-500 ring-2 ring-primary-300'
|
||||
: 'border-surface-300',
|
||||
executing ? 'animate-pulse' : '',
|
||||
node.mode === 4 ? 'opacity-50' : '', // bypassed
|
||||
error ? 'border-red-500 bg-red-50' : '',
|
||||
isDragging ? 'will-change-transform' : ''
|
||||
]"
|
||||
:style="{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
width: node.size ? `${node.size[0]}px` : 'auto',
|
||||
minWidth: '200px'
|
||||
}"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[node.title, node.color]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
@collapse="handleCollapse"
|
||||
/>
|
||||
|
||||
<!-- Node Body (only visible when not collapsed) -->
|
||||
<div v-if="!node.flags?.collapsed" class="flex flex-col gap-2 p-2">
|
||||
<!-- Slots only update when connections change -->
|
||||
<NodeSlots
|
||||
v-memo="[node.inputs?.length, node.outputs?.length]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
@slot-click="handleSlotClick"
|
||||
/>
|
||||
|
||||
<!-- Widgets update on value changes -->
|
||||
<NodeWidgets
|
||||
v-if="node.widgets?.length"
|
||||
v-memo="[
|
||||
node.widgets?.length,
|
||||
...(node.widgets?.map((w) => w.value) ?? [])
|
||||
]"
|
||||
:node="node"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<!-- Custom content area -->
|
||||
<NodeContent v-if="hasCustomContent" :node="node" :readonly="readonly" />
|
||||
</div>
|
||||
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
class="absolute bottom-0 left-0 h-1 bg-primary-500 transition-all duration-300"
|
||||
:style="{ width: `${progress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, reactive, ref, watch } from 'vue'
|
||||
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
|
||||
// Extended props for main node component
|
||||
interface LGraphNodeProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
selected?: boolean
|
||||
executing?: boolean
|
||||
progress?: number
|
||||
error?: string | null
|
||||
zoomLevel?: number
|
||||
}
|
||||
|
||||
const props = defineProps<LGraphNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'node-click': [event: PointerEvent, node: LGraphNode]
|
||||
'slot-click': [
|
||||
event: PointerEvent,
|
||||
node: LGraphNode,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
]
|
||||
collapse: []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node component error:', error)
|
||||
return false // Prevent error propagation
|
||||
})
|
||||
|
||||
// Position state - initialized from node.pos but then controlled via transforms
|
||||
const position = reactive({
|
||||
x: props.node.pos[0],
|
||||
y: props.node.pos[1]
|
||||
})
|
||||
|
||||
// Track dragging state for will-change optimization
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Only update position when node.pos changes AND we're not dragging
|
||||
// This prevents reflows during drag operations
|
||||
watch(
|
||||
() => props.node.pos,
|
||||
(newPos) => {
|
||||
if (!isDragging.value) {
|
||||
position.x = newPos[0]
|
||||
position.y = newPos[1]
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
// Check if node has custom content
|
||||
const hasCustomContent = computed(() => {
|
||||
// Currently all content is handled through widgets
|
||||
// This remains false but provides extensibility point
|
||||
return false
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
emit('node-click', event, props.node)
|
||||
// The parent component will handle setting isDragging when appropriate
|
||||
}
|
||||
|
||||
const handleCollapse = () => {
|
||||
// Parent component should handle node mutations
|
||||
// This is just emitting the event upwards
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
emit('slot-click', event, props.node, slotIndex, isInput)
|
||||
}
|
||||
|
||||
// Expose methods for parent to control position during drag
|
||||
defineExpose({
|
||||
setPosition(x: number, y: number) {
|
||||
position.x = x
|
||||
position.y = y
|
||||
},
|
||||
setDragging(dragging: boolean) {
|
||||
isDragging.value = dragging
|
||||
}
|
||||
})
|
||||
</script>
|
||||
33
src/components/graph/vueNodes/NodeContent.vue
Normal file
33
src/components/graph/vueNodes/NodeContent.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Content Error
|
||||
</div>
|
||||
<div v-else class="lg-node-content">
|
||||
<!-- Default slot for custom content -->
|
||||
<slot>
|
||||
<!-- This component serves as a placeholder for future extensibility -->
|
||||
<!-- Currently all node content is rendered through the widget system -->
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
interface NodeContentProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
defineProps<NodeContentProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node content error:', error)
|
||||
return false
|
||||
})
|
||||
</script>
|
||||
107
src/components/graph/vueNodes/NodeHeader.vue
Normal file
107
src/components/graph/vueNodes/NodeHeader.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Header Error
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header flex items-center justify-between px-3 py-2 rounded-t cursor-move"
|
||||
:style="{
|
||||
backgroundColor: headerColor,
|
||||
color: textColor
|
||||
}"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<!-- Node Title -->
|
||||
<span class="text-sm font-medium truncate flex-1">
|
||||
{{ node.title || node.constructor.title || 'Untitled' }}
|
||||
</span>
|
||||
|
||||
<!-- Node Controls -->
|
||||
<div class="flex items-center gap-1 ml-2">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-if="!readonly"
|
||||
class="lg-node-header__control p-0.5 rounded hover:bg-black/10 transition-colors"
|
||||
:title="node.flags?.collapsed ? 'Expand' : 'Collapse'"
|
||||
@click.stop="handleCollapse"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 transition-transform"
|
||||
:class="{ 'rotate-180': node.flags?.collapsed }"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Additional controls can be added here -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'title-edit': []
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node header error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Compute header color based on node color property or type
|
||||
const headerColor = computed(() => {
|
||||
if (props.node.color) {
|
||||
return props.node.color
|
||||
}
|
||||
// Default color based on node mode
|
||||
if (props.node.mode === 4) return '#666' // Bypassed
|
||||
if (props.node.mode === 2) return '#444' // Muted
|
||||
return '#353535' // Default
|
||||
})
|
||||
|
||||
// Compute text color for contrast
|
||||
const textColor = computed(() => {
|
||||
// Simple contrast calculation - could be improved
|
||||
const color = headerColor.value
|
||||
if (!color || color === '#353535' || color === '#444' || color === '#666') {
|
||||
return '#fff'
|
||||
}
|
||||
// For custom colors, use a simple heuristic
|
||||
const rgb = parseInt(color.slice(1), 16)
|
||||
const r = (rgb >> 16) & 255
|
||||
const g = (rgb >> 8) & 255
|
||||
const b = rgb & 255
|
||||
const brightness = (r * 299 + g * 587 + b * 114) / 1000
|
||||
return brightness > 128 ? '#000' : '#fff'
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
emit('collapse')
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (!props.readonly) {
|
||||
emit('title-edit')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
89
src/components/graph/vueNodes/NodeSlots.vue
Normal file
89
src/components/graph/vueNodes/NodeSlots.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Slots Error
|
||||
</div>
|
||||
<div v-else class="lg-node-slots relative">
|
||||
<!-- Input Slots -->
|
||||
<div
|
||||
v-if="node.inputs?.length"
|
||||
class="lg-node-slots__inputs absolute left-0 top-0 flex flex-col"
|
||||
>
|
||||
<InputSlot
|
||||
v-for="(input, index) in node.inputs"
|
||||
:key="`input-${index}`"
|
||||
:node="node"
|
||||
:slot-data="input"
|
||||
:index="index"
|
||||
:connected="isInputConnected(index)"
|
||||
:compatible="false"
|
||||
:readonly="readonly"
|
||||
@slot-click="(e) => handleSlotClick(e, index, true)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Output Slots -->
|
||||
<div
|
||||
v-if="node.outputs?.length"
|
||||
class="lg-node-slots__outputs absolute right-0 top-0 flex flex-col"
|
||||
>
|
||||
<OutputSlot
|
||||
v-for="(output, index) in node.outputs"
|
||||
:key="`output-${index}`"
|
||||
:node="node"
|
||||
:slot-data="output"
|
||||
:index="index"
|
||||
:connected="isOutputConnected(index)"
|
||||
:compatible="false"
|
||||
:readonly="readonly"
|
||||
@slot-click="(e) => handleSlotClick(e, index, false)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import InputSlot from './InputSlot.vue'
|
||||
import OutputSlot from './OutputSlot.vue'
|
||||
|
||||
interface NodeSlotsProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeSlotsProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent, slotIndex: number, isInput: boolean]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node slots error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Check if input slot has a connection
|
||||
const isInputConnected = (index: number) => {
|
||||
return props.node.inputs?.[index]?.link != null
|
||||
}
|
||||
|
||||
// Check if output slot has any connections
|
||||
const isOutputConnected = (index: number) => {
|
||||
return (props.node.outputs?.[index]?.links?.length ?? 0) > 0
|
||||
}
|
||||
|
||||
// Handle slot click events
|
||||
const handleSlotClick = (
|
||||
event: PointerEvent,
|
||||
slotIndex: number,
|
||||
isInput: boolean
|
||||
) => {
|
||||
emit('slot-click', event, slotIndex, isInput)
|
||||
}
|
||||
</script>
|
||||
97
src/components/graph/vueNodes/NodeWidgets.vue
Normal file
97
src/components/graph/vueNodes/NodeWidgets.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
⚠️ Node Widgets Error
|
||||
</div>
|
||||
<div v-else class="lg-node-widgets flex flex-col gap-2">
|
||||
<component
|
||||
:is="getWidgetComponent(widget)"
|
||||
v-for="(widget, index) in widgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
v-model="widget.value"
|
||||
:widget="simplifiedWidget(widget)"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(value: any) => handleWidgetUpdate(widget, value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import {
|
||||
WidgetType,
|
||||
getWidgetComponent as getWidgetComponentFromRegistry
|
||||
} from '@/components/graph/vueWidgets/widgetRegistry'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
interface NodeWidgetsProps {
|
||||
node: LGraphNode
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<NodeWidgetsProps>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue node widgets error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get non-hidden widgets
|
||||
const widgets = computed(() => {
|
||||
return props.node.widgets?.filter((w) => !w.options?.hidden) || []
|
||||
})
|
||||
|
||||
// Map widget type to our widget registry
|
||||
const getWidgetComponent = (widget: IBaseWidget) => {
|
||||
// Map LiteGraph widget types to our WidgetType enum
|
||||
const typeMapping: Record<string, WidgetType> = {
|
||||
number: WidgetType.SLIDER,
|
||||
float: WidgetType.SLIDER,
|
||||
int: WidgetType.SLIDER,
|
||||
string: WidgetType.STRING,
|
||||
text: WidgetType.TEXTAREA,
|
||||
combo: WidgetType.COMBO,
|
||||
toggle: WidgetType.BOOLEAN,
|
||||
boolean: WidgetType.BOOLEAN,
|
||||
button: WidgetType.BUTTON,
|
||||
color: WidgetType.COLOR,
|
||||
image: WidgetType.IMAGE,
|
||||
file: WidgetType.FILEUPLOAD
|
||||
}
|
||||
|
||||
const widgetType = typeMapping[widget.type] || widget.type.toUpperCase()
|
||||
return getWidgetComponentFromRegistry(widgetType) || null
|
||||
}
|
||||
|
||||
// Convert LiteGraph widget to SimplifiedWidget interface
|
||||
const simplifiedWidget = (widget: IBaseWidget): SimplifiedWidget => {
|
||||
return {
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: widget.value,
|
||||
options: widget.options,
|
||||
callback: widget.callback
|
||||
}
|
||||
}
|
||||
|
||||
// Handle widget value updates
|
||||
const handleWidgetUpdate = (widget: IBaseWidget, value: any) => {
|
||||
widget.value = value
|
||||
|
||||
// Call widget callback if exists
|
||||
if (widget.callback) {
|
||||
widget.callback(value)
|
||||
}
|
||||
|
||||
// Mark node as dirty for LiteGraph
|
||||
if (props.node.onWidgetChanged) {
|
||||
props.node.onWidgetChanged(widget.name, value, null, widget)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
68
src/components/graph/vueNodes/OutputSlot.vue
Normal file
68
src/components/graph/vueNodes/OutputSlot.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs">⚠️</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-slot lg-slot--output flex items-center gap-2 py-1 pr-2 pl-4 cursor-crosshair hover:bg-black/5 justify-end"
|
||||
:class="{
|
||||
'opacity-70': readonly,
|
||||
'lg-slot--connected': connected,
|
||||
'lg-slot--compatible': compatible
|
||||
}"
|
||||
@pointerdown="handleClick"
|
||||
>
|
||||
<!-- Slot Name -->
|
||||
<span class="text-xs text-surface-700 whitespace-nowrap">
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Connection Dot -->
|
||||
<div
|
||||
class="lg-slot__dot w-3 h-3 rounded-full border-2"
|
||||
:style="{
|
||||
backgroundColor: connected ? slotColor : 'transparent',
|
||||
borderColor: slotColor
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeSlot, LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computed, onErrorCaptured, ref } from 'vue'
|
||||
|
||||
import { getSlotColor } from '@/constants/slotColors'
|
||||
|
||||
interface OutputSlotProps {
|
||||
node: LGraphNode
|
||||
slotData: INodeSlot
|
||||
index: number
|
||||
connected?: boolean
|
||||
compatible?: boolean
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<OutputSlotProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
// Error boundary implementation
|
||||
const renderError = ref<string | null>(null)
|
||||
|
||||
onErrorCaptured((error) => {
|
||||
renderError.value = error.message
|
||||
console.error('Vue output slot error:', error)
|
||||
return false
|
||||
})
|
||||
|
||||
// Get slot color based on type
|
||||
const slotColor = computed(() => getSlotColor(props.slotData.type))
|
||||
|
||||
// Handle click events
|
||||
const handleClick = (event: PointerEvent) => {
|
||||
if (!props.readonly) {
|
||||
emit('slot-click', event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user