mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-06 16:10:09 +00:00
vue node prototype
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
<SelectionToolbox />
|
||||
</SelectionOverlay>
|
||||
<DomWidgets />
|
||||
<VueNodeOverlay v-if="vueNodeRenderingEnabled" />
|
||||
</template>
|
||||
<SubgraphBreadcrumb />
|
||||
</template>
|
||||
@@ -53,6 +54,7 @@ import SubgraphBreadcrumb from '@/components/breadcrumb/SubgraphBreadcrumb.vue'
|
||||
import DomWidgets from '@/components/graph/DomWidgets.vue'
|
||||
import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import VueNodeOverlay from '@/components/graph/nodes/VueNodeOverlay.vue'
|
||||
import SelectionOverlay from '@/components/graph/SelectionOverlay.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
@@ -110,6 +112,8 @@ const tooltipEnabled = computed(() => settingStore.get('Comfy.EnableTooltips'))
|
||||
const selectionToolboxEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Canvas.SelectionToolbox')
|
||||
)
|
||||
// Temporarily enable Vue node rendering for testing
|
||||
const vueNodeRenderingEnabled = computed(() => true)
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
|
||||
142
src/components/graph/nodes/VueNode.vue
Normal file
142
src/components/graph/nodes/VueNode.vue
Normal file
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node pointer-events-auto flex flex-col bg-white dark-theme:bg-gray-800 border border-gray-300 dark-theme:border-gray-600 rounded shadow-lg"
|
||||
:class="nodeClasses"
|
||||
:style="nodeStyle"
|
||||
@mousedown="onMouseDown"
|
||||
@contextmenu="onContextMenu"
|
||||
>
|
||||
<VueNodeHeader
|
||||
:node="node"
|
||||
:title="node.title"
|
||||
:nodeType="node.type"
|
||||
@title-edit="onTitleEdit"
|
||||
/>
|
||||
|
||||
<VueNodeSlots
|
||||
v-if="!node.collapsed"
|
||||
:inputs="node.inputs || []"
|
||||
:outputs="node.outputs || []"
|
||||
@slot-click="onSlotInteraction"
|
||||
/>
|
||||
|
||||
<!-- Flexbox container for widgets - no manual height calculations needed -->
|
||||
<VueNodeBody
|
||||
v-if="!node.collapsed"
|
||||
class="flex flex-col gap-2 p-2 flex-grow"
|
||||
:widgets="nodeWidgets"
|
||||
:node="node"
|
||||
@widget-change="onWidgetChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { NodeInteractionEvent } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import type { NodePosition } from '@/composables/nodeRendering/useNodePositionSync'
|
||||
import VueNodeHeader from './VueNodeHeader.vue'
|
||||
import VueNodeSlots from './VueNodeSlots.vue'
|
||||
import VueNodeBody from './VueNodeBody.vue'
|
||||
|
||||
interface VueNodeProps {
|
||||
node: LGraphNode
|
||||
position?: NodePosition
|
||||
selected: boolean
|
||||
executing: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
interaction: [event: NodeInteractionEvent]
|
||||
}>()
|
||||
|
||||
// Node styling based on position and state
|
||||
const nodeStyle = computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
left: props.position ? `${props.position.x}px` : '0px',
|
||||
top: props.position ? `${props.position.y}px` : '0px',
|
||||
minWidth: props.position ? `${props.position.width}px` : '150px',
|
||||
// Height is now determined by flexbox content, not manual calculations
|
||||
zIndex: props.selected ? 10 : 1,
|
||||
}))
|
||||
|
||||
// Node CSS classes based on state
|
||||
const nodeClasses = computed(() => ({
|
||||
'vue-node--selected': props.selected,
|
||||
'vue-node--executing': props.executing,
|
||||
'vue-node--collapsed': props.node.collapsed,
|
||||
[`vue-node--${props.node.type?.replace(/[^a-zA-Z0-9]/g, '-')}`]: props.node.type
|
||||
}))
|
||||
|
||||
// Extract widgets from the node
|
||||
const nodeWidgets = computed(() => {
|
||||
return props.node.widgets || []
|
||||
})
|
||||
|
||||
// Event handlers
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
emit('interaction', {
|
||||
type: 'mousedown',
|
||||
nodeId: String(props.node.id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
const onContextMenu = (event: MouseEvent) => {
|
||||
emit('interaction', {
|
||||
type: 'contextmenu',
|
||||
nodeId: String(props.node.id),
|
||||
originalEvent: event
|
||||
})
|
||||
}
|
||||
|
||||
const onSlotInteraction = (slotIndex: number, event: MouseEvent) => {
|
||||
emit('interaction', {
|
||||
type: 'slot-click',
|
||||
nodeId: String(props.node.id),
|
||||
originalEvent: event,
|
||||
slotIndex
|
||||
})
|
||||
}
|
||||
|
||||
const onTitleEdit = (newTitle: string) => {
|
||||
props.node.title = newTitle
|
||||
}
|
||||
|
||||
const onWidgetChange = (widgetIndex: number, value: any) => {
|
||||
if (props.node.widgets?.[widgetIndex]) {
|
||||
props.node.widgets[widgetIndex].value = value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node {
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.vue-node--selected {
|
||||
@apply ring-2 ring-blue-500 shadow-xl;
|
||||
}
|
||||
|
||||
.vue-node--executing {
|
||||
@apply ring-2 ring-green-500;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.vue-node--collapsed {
|
||||
@apply h-8;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(34, 197, 94, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
117
src/components/graph/nodes/VueNodeBody.vue
Normal file
117
src/components/graph/nodes/VueNodeBody.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div class="vue-node-body">
|
||||
<!-- Render widgets using existing Vue widget system -->
|
||||
<div
|
||||
v-for="(widget, index) in widgets"
|
||||
:key="`widget-${index}`"
|
||||
class="widget-container mb-2 last:mb-0"
|
||||
>
|
||||
<!-- Use existing Vue widget components if available -->
|
||||
<component
|
||||
v-if="getWidgetComponent(widget)"
|
||||
:is="getWidgetComponent(widget)"
|
||||
:widget="widget"
|
||||
v-model="widget.value"
|
||||
@update:model-value="onWidgetChange(index, $event)"
|
||||
/>
|
||||
|
||||
<!-- Fallback for non-Vue widgets -->
|
||||
<div
|
||||
v-else
|
||||
class="legacy-widget p-2 bg-gray-50 dark-theme:bg-gray-700 rounded text-sm"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium">{{ widget.name || 'Widget' }}</span>
|
||||
<span class="text-gray-500">{{ widget.type || 'unknown' }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-gray-600 dark-theme:text-gray-400">
|
||||
Value: {{ formatWidgetValue(widget.value) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message when no widgets -->
|
||||
<div
|
||||
v-if="widgets.length === 0"
|
||||
class="text-center text-gray-500 dark-theme:text-gray-400 text-sm py-2"
|
||||
>
|
||||
No widgets
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { BaseWidget } from '@comfyorg/litegraph'
|
||||
// Import existing Vue widget components
|
||||
import StringWidget from '@/components/graph/widgets/StringWidget.vue'
|
||||
import ColorPickerWidget from '@/components/graph/widgets/ColorPickerWidget.vue'
|
||||
import ImagePreviewWidget from '@/components/graph/widgets/ImagePreviewWidget.vue'
|
||||
import ImageUploadWidget from '@/components/graph/widgets/ImageUploadWidget.vue'
|
||||
|
||||
interface VueNodeBodyProps {
|
||||
widgets: BaseWidget[]
|
||||
node: LGraphNode
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeBodyProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'widget-change': [widgetIndex: number, value: any]
|
||||
}>()
|
||||
|
||||
// Map widget types to Vue components
|
||||
const widgetComponentMap: Record<string, any> = {
|
||||
'STRING': StringWidget,
|
||||
'text': StringWidget,
|
||||
'COLOR': ColorPickerWidget,
|
||||
'color': ColorPickerWidget,
|
||||
'IMAGE': ImagePreviewWidget,
|
||||
'image': ImagePreviewWidget,
|
||||
'IMAGEUPLOAD': ImageUploadWidget,
|
||||
'image_upload': ImageUploadWidget
|
||||
}
|
||||
|
||||
// Get the Vue component for a widget type
|
||||
const getWidgetComponent = (widget: BaseWidget) => {
|
||||
const widgetType = widget.type?.toUpperCase()
|
||||
return widgetType ? widgetComponentMap[widgetType] : null
|
||||
}
|
||||
|
||||
// Format widget value for display
|
||||
const formatWidgetValue = (value: any) => {
|
||||
if (value === null || value === undefined) return 'null'
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2)
|
||||
if (typeof value === 'string' && value.length > 50) {
|
||||
return value.substring(0, 47) + '...'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const onWidgetChange = (index: number, value: any) => {
|
||||
emit('widget-change', index, value)
|
||||
|
||||
// Trigger node property change if the widget has a callback
|
||||
const widget = props.widgets[index]
|
||||
if (widget?.callback) {
|
||||
// Note: callback signature may need adjustment based on widget requirements
|
||||
widget.callback(value, null, props.node, [0, 0], {})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-body {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.widget-container {
|
||||
/* Widget containers use flexbox for natural sizing */
|
||||
}
|
||||
|
||||
.legacy-widget {
|
||||
/* Styling for non-Vue widgets */
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
</style>
|
||||
101
src/components/graph/nodes/VueNodeHeader.vue
Normal file
101
src/components/graph/nodes/VueNodeHeader.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="vue-node-header flex items-center justify-between p-2 bg-gray-100 dark-theme:bg-gray-700 rounded-t">
|
||||
<div class="flex items-center gap-2 flex-grow">
|
||||
<!-- Node type badge -->
|
||||
<span
|
||||
v-if="nodeType"
|
||||
class="text-xs px-2 py-1 bg-blue-100 dark-theme:bg-blue-900 text-blue-800 dark-theme:text-blue-200 rounded"
|
||||
>
|
||||
{{ nodeType }}
|
||||
</span>
|
||||
|
||||
<!-- Editable title -->
|
||||
<EditableText
|
||||
v-model="editableTitle"
|
||||
class="font-medium text-sm flex-grow"
|
||||
@update:model-value="onTitleUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node controls -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Collapse/expand button -->
|
||||
<button
|
||||
v-if="node.constructor?.collapsable !== false"
|
||||
class="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-700 dark-theme:text-gray-400 dark-theme:hover:text-gray-200 rounded hover:bg-gray-200 dark-theme:hover:bg-gray-600"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<i class="mdi mdi-chevron-up" :class="{ 'rotate-180': node.collapsed }"></i>
|
||||
</button>
|
||||
|
||||
<!-- Pin button -->
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center text-gray-500 hover:text-gray-700 dark-theme:text-gray-400 dark-theme:hover:text-gray-200 rounded hover:bg-gray-200 dark-theme:hover:bg-gray-600"
|
||||
:class="{ 'text-blue-600 dark-theme:text-blue-400': node.pinned }"
|
||||
@click="togglePin"
|
||||
>
|
||||
<i class="mdi mdi-pin" :class="{ 'mdi-pin-off': !node.pinned }"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import EditableText from '@/components/common/EditableText.vue'
|
||||
|
||||
interface VueNodeHeaderProps {
|
||||
node: LGraphNode
|
||||
title: string
|
||||
nodeType?: string
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeHeaderProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'title-edit': [title: string]
|
||||
}>()
|
||||
|
||||
// Local editable title
|
||||
const editableTitle = ref(props.title)
|
||||
|
||||
// Watch for external title changes
|
||||
watch(() => props.title, (newTitle) => {
|
||||
editableTitle.value = newTitle
|
||||
})
|
||||
|
||||
const onTitleUpdate = (newTitle: string) => {
|
||||
emit('title-edit', newTitle)
|
||||
}
|
||||
|
||||
const toggleCollapse = () => {
|
||||
// Use node collapse method instead of setting property directly
|
||||
if (props.node.collapse) {
|
||||
props.node.collapse()
|
||||
} else {
|
||||
// Fallback to manual property setting if method doesn't exist
|
||||
;(props.node as any).collapsed = !props.node.collapsed
|
||||
}
|
||||
// Trigger canvas redraw
|
||||
props.node.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
|
||||
const togglePin = () => {
|
||||
// Use pin method if available, otherwise set property
|
||||
if (props.node.pin) {
|
||||
props.node.pin()
|
||||
} else {
|
||||
// Fallback to manual property setting if method doesn't exist
|
||||
;(props.node as any).pinned = !props.node.pinned
|
||||
}
|
||||
// Trigger canvas redraw
|
||||
props.node.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
60
src/components/graph/nodes/VueNodeOverlay.vue
Normal file
60
src/components/graph/nodes/VueNodeOverlay.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-node-overlay absolute inset-0 pointer-events-none overflow-hidden"
|
||||
:style="overlayStyle"
|
||||
>
|
||||
<VueNode
|
||||
v-for="node in visibleNodes"
|
||||
:key="node.id"
|
||||
:node="node"
|
||||
:position="nodePositions[node.id]"
|
||||
:selected="isNodeSelected(node.id)"
|
||||
:executing="isNodeExecuting(node.id)"
|
||||
@interaction="handleNodeInteraction"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useNodePositionSync } from '@/composables/nodeRendering/useNodePositionSync'
|
||||
import { useNodeInteractionProxy } from '@/composables/nodeRendering/useNodeInteractionProxy'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import VueNode from './VueNode.vue'
|
||||
|
||||
const {
|
||||
nodePositions,
|
||||
canvasScale,
|
||||
canvasOffset,
|
||||
visibleNodes
|
||||
} = useNodePositionSync()
|
||||
|
||||
const { handleNodeInteraction } = useNodeInteractionProxy()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionStore = useExecutionStore()
|
||||
|
||||
// Transform style for the overlay to match canvas transform
|
||||
const overlayStyle = computed(() => ({
|
||||
transform: `scale(${canvasScale.value}) translate(${canvasOffset.value.x}px, ${canvasOffset.value.y}px)`,
|
||||
transformOrigin: '0 0'
|
||||
}))
|
||||
|
||||
// Check if node is selected
|
||||
const isNodeSelected = (nodeId: string) => {
|
||||
return canvasStore.selectedItems.has(Number(nodeId))
|
||||
}
|
||||
|
||||
// Check if node is executing
|
||||
const isNodeExecuting = (nodeId: string) => {
|
||||
return executionStore.executingNodeId === Number(nodeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-overlay {
|
||||
/* Ensure overlay doesn't interfere with canvas interactions */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
143
src/components/graph/nodes/VueNodeSlots.vue
Normal file
143
src/components/graph/nodes/VueNodeSlots.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<div class="vue-node-slots">
|
||||
<!-- Input slots -->
|
||||
<div v-if="inputs.length > 0" class="inputs mb-2">
|
||||
<div
|
||||
v-for="(input, index) in inputs"
|
||||
:key="`input-${index}`"
|
||||
class="input-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'input')"
|
||||
>
|
||||
<!-- Input connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(input.type, 'input')"
|
||||
></div>
|
||||
|
||||
<!-- Input label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow">
|
||||
{{ input.name || `Input ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Input type badge -->
|
||||
<span
|
||||
v-if="input.type && input.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ input.type }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Output slots -->
|
||||
<div v-if="outputs.length > 0" class="outputs">
|
||||
<div
|
||||
v-for="(output, index) in outputs"
|
||||
:key="`output-${index}`"
|
||||
class="output-slot flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark-theme:hover:bg-gray-700"
|
||||
@click="onSlotClick(index, $event, 'output')"
|
||||
>
|
||||
<!-- Output type badge -->
|
||||
<span
|
||||
v-if="output.type && output.type !== '*'"
|
||||
class="text-xs px-1 py-0.5 bg-gray-200 dark-theme:bg-gray-600 text-gray-600 dark-theme:text-gray-400 rounded"
|
||||
>
|
||||
{{ output.type }}
|
||||
</span>
|
||||
|
||||
<!-- Output label -->
|
||||
<span class="text-sm text-gray-700 dark-theme:text-gray-300 flex-grow text-right">
|
||||
{{ output.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Output connection point -->
|
||||
<div
|
||||
class="slot-connector w-3 h-3 rounded-full border-2 border-gray-400 bg-white dark-theme:bg-gray-800"
|
||||
:class="getSlotColor(output.type, 'output')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { INodeInputSlot, INodeOutputSlot } from '@comfyorg/litegraph'
|
||||
|
||||
interface VueNodeSlotsProps {
|
||||
inputs: INodeInputSlot[]
|
||||
outputs: INodeOutputSlot[]
|
||||
}
|
||||
|
||||
const props = defineProps<VueNodeSlotsProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'slot-click': [slotIndex: number, event: MouseEvent]
|
||||
}>()
|
||||
|
||||
// Color mapping for different slot types
|
||||
const getSlotColor = (type: string | number | undefined, _direction: 'input' | 'output') => {
|
||||
if (!type || type === '*') {
|
||||
return 'border-gray-400'
|
||||
}
|
||||
|
||||
// Convert type to string for lookup
|
||||
const typeStr = String(type)
|
||||
|
||||
// Map common ComfyUI types to colors
|
||||
const typeColors: Record<string, string> = {
|
||||
'IMAGE': 'border-green-500 bg-green-100 dark-theme:bg-green-900',
|
||||
'LATENT': 'border-purple-500 bg-purple-100 dark-theme:bg-purple-900',
|
||||
'MODEL': 'border-blue-500 bg-blue-100 dark-theme:bg-blue-900',
|
||||
'CONDITIONING': 'border-yellow-500 bg-yellow-100 dark-theme:bg-yellow-900',
|
||||
'VAE': 'border-red-500 bg-red-100 dark-theme:bg-red-900',
|
||||
'CLIP': 'border-orange-500 bg-orange-100 dark-theme:bg-orange-900',
|
||||
'STRING': 'border-gray-500 bg-gray-100 dark-theme:bg-gray-900',
|
||||
'INT': 'border-indigo-500 bg-indigo-100 dark-theme:bg-indigo-900',
|
||||
'FLOAT': 'border-pink-500 bg-pink-100 dark-theme:bg-pink-900'
|
||||
}
|
||||
|
||||
return typeColors[typeStr.toUpperCase()] || 'border-gray-400'
|
||||
}
|
||||
|
||||
const onSlotClick = (index: number, event: MouseEvent, slotType: 'input' | 'output') => {
|
||||
event.stopPropagation()
|
||||
|
||||
// Calculate the actual slot index based on type
|
||||
// For outputs, we need to add the input count to get the correct index
|
||||
const slotIndex = slotType === 'output' ? props.inputs.length + index : index
|
||||
|
||||
emit('slot-click', slotIndex, event)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vue-node-slots {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slot-connector {
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slot-connector:hover {
|
||||
transform: scale(1.2);
|
||||
border-width: 3px;
|
||||
}
|
||||
|
||||
.input-slot {
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.output-slot {
|
||||
border-right: 3px solid transparent;
|
||||
}
|
||||
|
||||
.input-slot:hover {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.output-slot:hover {
|
||||
border-right-color: #3b82f6;
|
||||
}
|
||||
</style>
|
||||
71
src/composables/nodeRendering/useNodeInteractionProxy.ts
Normal file
71
src/composables/nodeRendering/useNodeInteractionProxy.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { computed } from 'vue'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
|
||||
export interface NodeInteractionEvent {
|
||||
type: 'mousedown' | 'contextmenu' | 'slot-click'
|
||||
nodeId: string
|
||||
originalEvent: MouseEvent
|
||||
slotIndex?: number
|
||||
}
|
||||
|
||||
export function useNodeInteractionProxy() {
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
const handleNodeInteraction = (event: NodeInteractionEvent) => {
|
||||
const { type, nodeId, originalEvent } = event
|
||||
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return
|
||||
|
||||
switch (type) {
|
||||
case 'mousedown':
|
||||
// Convert Vue event coordinates back to canvas coordinates
|
||||
const rect = canvas.value.canvas.getBoundingClientRect()
|
||||
const canvasX = originalEvent.clientX - rect.left
|
||||
const canvasY = originalEvent.clientY - rect.top
|
||||
|
||||
// Transform to graph coordinates
|
||||
const graphPos = canvas.value.convertOffsetToCanvas([canvasX, canvasY])
|
||||
|
||||
// Note: simulatedEvent not currently used but kept for future expansion
|
||||
|
||||
// Trigger node selection and dragging
|
||||
canvas.value.selectNode(node, originalEvent.ctrlKey || originalEvent.metaKey)
|
||||
canvas.value.node_dragged = node
|
||||
|
||||
// Start drag operation if not holding modifier keys
|
||||
if (!originalEvent.ctrlKey && !originalEvent.metaKey && !originalEvent.shiftKey) {
|
||||
canvas.value.dragging_canvas = false
|
||||
canvas.value.node_dragged = node
|
||||
canvas.value.drag_start = [originalEvent.clientX, originalEvent.clientY]
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'contextmenu':
|
||||
// Show context menu for the node
|
||||
originalEvent.preventDefault()
|
||||
canvas.value.showContextMenu(originalEvent, node)
|
||||
break
|
||||
|
||||
case 'slot-click':
|
||||
// Handle slot connection interactions
|
||||
if (event.slotIndex !== undefined) {
|
||||
const slot = node.inputs?.[event.slotIndex] || node.outputs?.[event.slotIndex]
|
||||
if (slot) {
|
||||
canvas.value.processSlotClick(node, event.slotIndex, originalEvent)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleNodeInteraction
|
||||
}
|
||||
}
|
||||
113
src/composables/nodeRendering/useNodePositionSync.ts
Normal file
113
src/composables/nodeRendering/useNodePositionSync.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { ref, computed, readonly, watchEffect } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
// Note: useEventListener imported but not currently used - may be used for future enhancements
|
||||
|
||||
export interface NodePosition {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export function useNodePositionSync() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodePositions = ref<Record<string, NodePosition>>({})
|
||||
const canvasScale = ref(1)
|
||||
const canvasOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Sync canvas transform (scale and offset)
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
const updateTransform = () => {
|
||||
if (!canvas.value?.ds) return
|
||||
|
||||
canvasScale.value = canvas.value.ds.scale
|
||||
canvasOffset.value = {
|
||||
x: canvas.value.ds.offset[0],
|
||||
y: canvas.value.ds.offset[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Hook into the canvas draw cycle to update transform
|
||||
canvas.value.onDrawForeground = useChainCallback(
|
||||
canvas.value.onDrawForeground,
|
||||
updateTransform
|
||||
)
|
||||
|
||||
// Initial transform update
|
||||
updateTransform()
|
||||
})
|
||||
|
||||
// Sync node positions
|
||||
const syncNodePositions = () => {
|
||||
if (!canvas.value?.graph) return
|
||||
|
||||
const positions: Record<string, NodePosition> = {}
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
positions[node.id] = {
|
||||
id: String(node.id),
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
}
|
||||
nodePositions.value = positions
|
||||
}
|
||||
|
||||
// Listen for node position changes
|
||||
watchEffect(() => {
|
||||
if (!canvas.value) return
|
||||
|
||||
// Hook into various node update events
|
||||
const originalOnNodeMoved = canvas.value.onNodeMoved
|
||||
canvas.value.onNodeMoved = useChainCallback(
|
||||
originalOnNodeMoved,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Hook into general graph changes
|
||||
const originalOnGraphChanged = canvas.value.onGraphChanged
|
||||
canvas.value.onGraphChanged = useChainCallback(
|
||||
originalOnGraphChanged,
|
||||
syncNodePositions
|
||||
)
|
||||
|
||||
// Initial sync
|
||||
syncNodePositions()
|
||||
})
|
||||
|
||||
// Get visible nodes (within viewport bounds)
|
||||
const visibleNodes = computed(() => {
|
||||
if (!canvas.value?.graph) return []
|
||||
|
||||
const nodes = canvas.value.graph._nodes.filter((node: LGraphNode) => {
|
||||
// Only return nodes that have phantom_mode enabled
|
||||
return node.phantom_mode === true
|
||||
})
|
||||
|
||||
// TODO: Add viewport culling for performance
|
||||
// For now, return all phantom nodes
|
||||
return nodes
|
||||
})
|
||||
|
||||
// Manual sync function for external triggers
|
||||
const forceSync = () => {
|
||||
syncNodePositions()
|
||||
}
|
||||
|
||||
return {
|
||||
nodePositions: readonly(nodePositions),
|
||||
canvasScale: readonly(canvasScale),
|
||||
canvasOffset: readonly(canvasOffset),
|
||||
visibleNodes: readonly(visibleNodes),
|
||||
forceSync
|
||||
}
|
||||
}
|
||||
142
src/composables/nodeRendering/usePhantomNodes.ts
Normal file
142
src/composables/nodeRendering/usePhantomNodes.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
export function usePhantomNodes() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// Get canvas reference
|
||||
const canvas = computed(() => canvasStore.canvas)
|
||||
|
||||
// Check if Vue node rendering is enabled
|
||||
const vueRenderingEnabled = computed(() => true) // Temporarily enabled for testing
|
||||
|
||||
/**
|
||||
* Enable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make phantom
|
||||
*/
|
||||
const enablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = true
|
||||
// Trigger canvas redraw to hide the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to make visible again
|
||||
*/
|
||||
const disablePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
node.phantom_mode = false
|
||||
// Trigger canvas redraw to show the node visually
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle phantom mode for a specific node
|
||||
* @param nodeId The ID of the node to toggle
|
||||
*/
|
||||
const togglePhantomMode = (nodeId: string | number) => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
if (!node) return false
|
||||
|
||||
const newMode = !node.phantom_mode
|
||||
node.phantom_mode = newMode
|
||||
// Trigger canvas redraw
|
||||
canvas.value.setDirty(true, true)
|
||||
|
||||
return newMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable phantom mode for all nodes (global Vue rendering)
|
||||
*/
|
||||
const enableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (!node.phantom_mode) {
|
||||
node.phantom_mode = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable phantom mode for all nodes (back to canvas rendering)
|
||||
*/
|
||||
const disableAllPhantomMode = () => {
|
||||
if (!canvas.value?.graph) return 0
|
||||
|
||||
let count = 0
|
||||
for (const node of canvas.value.graph._nodes) {
|
||||
if (node.phantom_mode) {
|
||||
node.phantom_mode = false
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
canvas.value.setDirty(true, true)
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all phantom nodes
|
||||
*/
|
||||
const getPhantomNodes = (): LGraphNode[] => {
|
||||
if (!canvas.value?.graph) return []
|
||||
|
||||
return canvas.value.graph._nodes.filter((node: LGraphNode) =>
|
||||
node.phantom_mode === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is in phantom mode
|
||||
* @param nodeId The ID of the node to check
|
||||
*/
|
||||
const isPhantomNode = (nodeId: string | number): boolean => {
|
||||
if (!canvas.value?.graph) return false
|
||||
|
||||
const node = canvas.value.graph.getNodeById(Number(nodeId))
|
||||
return node?.phantom_mode === true
|
||||
}
|
||||
|
||||
return {
|
||||
vueRenderingEnabled,
|
||||
enablePhantomMode,
|
||||
disablePhantomMode,
|
||||
togglePhantomMode,
|
||||
enableAllPhantomMode,
|
||||
disableAllPhantomMode,
|
||||
getPhantomNodes,
|
||||
isPhantomNode
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
type BadgeState = 'normal' | 'random' | 'lock' | 'increment' | 'decrement'
|
||||
|
||||
@@ -16,7 +15,6 @@ interface BadgedNumberInputOptions {
|
||||
defaultValue?: number
|
||||
badgeState?: BadgeState
|
||||
disabled?: boolean
|
||||
minHeight?: number
|
||||
serialize?: boolean
|
||||
mode?: NumberWidgetMode
|
||||
}
|
||||
@@ -43,7 +41,6 @@ export const useBadgedNumberInput = (
|
||||
const {
|
||||
defaultValue = 0,
|
||||
disabled = false,
|
||||
minHeight = 32,
|
||||
serialize = true,
|
||||
mode = 'int'
|
||||
} = options
|
||||
@@ -115,10 +112,6 @@ export const useBadgedNumberInput = (
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => minHeight + PADDING,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 48,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useChatHistoryWidget = (
|
||||
options: {
|
||||
@@ -32,7 +31,6 @@ export const useChatHistoryWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => 400 + PADDING
|
||||
}
|
||||
})
|
||||
addWidget(node, widget)
|
||||
|
||||
@@ -6,12 +6,9 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
interface ColorPickerWidgetOptions {
|
||||
defaultValue?: string
|
||||
defaultFormat?: 'rgba' | 'hsla' | 'hsva' | 'hex'
|
||||
minHeight?: number
|
||||
serialize?: boolean
|
||||
}
|
||||
|
||||
@@ -20,7 +17,6 @@ export const useColorPickerWidget = (
|
||||
) => {
|
||||
const {
|
||||
defaultValue = 'rgba(255, 0, 0, 1)',
|
||||
minHeight = 48,
|
||||
serialize = true
|
||||
} = options
|
||||
|
||||
@@ -64,9 +60,6 @@ export const useColorPickerWidget = (
|
||||
}
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => minHeight + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize
|
||||
}
|
||||
|
||||
@@ -30,10 +30,6 @@ const addMultiSelectWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
setValue: (value: string[]) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
// Optional: minimum height for the widget (multiselect needs minimal height)
|
||||
getMinHeight: () => 24,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 32,
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
|
||||
@@ -49,10 +49,6 @@ export const useDropdownComboWidget = (
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget (dropdown needs minimal height)
|
||||
getMinHeight: () => 32,
|
||||
// Lock maximum height to prevent oversizing
|
||||
getMaxHeight: () => 48,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
|
||||
|
||||
export const useImagePreviewWidget = (
|
||||
options: { defaultValue?: string | string[] } = {}
|
||||
@@ -35,10 +35,6 @@ export const useImagePreviewWidget = (
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => 320 + PADDING,
|
||||
getMaxHeight: () => 512 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: false
|
||||
}
|
||||
|
||||
@@ -94,38 +94,6 @@ export const useImageUploadMediaWidget = () => {
|
||||
// Convert the V1 input spec to V2 format for the MediaLoader widget
|
||||
const inputSpecV2 = transformInputSpecV1ToV2(inputData, { name: inputName })
|
||||
|
||||
// Handle widget dimensions based on input options
|
||||
const getMinHeight = () => {
|
||||
// Use smaller height for MediaLoader upload widget
|
||||
let baseHeight = 176
|
||||
|
||||
// Handle multiline attribute for expanded height
|
||||
if (inputOptions.multiline) {
|
||||
baseHeight = Math.max(
|
||||
baseHeight,
|
||||
inputOptions.multiline === true
|
||||
? 120
|
||||
: Number(inputOptions.multiline) || 120
|
||||
)
|
||||
}
|
||||
|
||||
// Handle other height-related attributes
|
||||
if (inputOptions.min_height) {
|
||||
baseHeight = Math.max(baseHeight, Number(inputOptions.min_height))
|
||||
}
|
||||
|
||||
return baseHeight + 8 // Add padding
|
||||
}
|
||||
|
||||
const getMaxHeight = () => {
|
||||
// Lock maximum height to prevent oversizing of upload widget
|
||||
if (inputOptions.multiline || inputOptions.min_height) {
|
||||
// Allow more height for special cases
|
||||
return Math.max(200, getMinHeight())
|
||||
}
|
||||
// Lock standard upload widget to ~80px max
|
||||
return 80
|
||||
}
|
||||
|
||||
// State for MediaLoader widget
|
||||
const uploadedFiles = ref<string[]>([])
|
||||
@@ -145,8 +113,6 @@ export const useImageUploadMediaWidget = () => {
|
||||
setValue: (value: string[]) => {
|
||||
uploadedFiles.value = value
|
||||
},
|
||||
getMinHeight,
|
||||
getMaxHeight, // Lock maximum height to prevent oversizing
|
||||
serialize: false,
|
||||
onFilesSelected: async (files: File[]) => {
|
||||
const isPastedFile = (file: File): boolean =>
|
||||
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
} from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
|
||||
interface MediaLoaderOptions {
|
||||
defaultValue?: string[]
|
||||
minHeight?: number
|
||||
accept?: string
|
||||
onFilesSelected?: (files: File[]) => void
|
||||
}
|
||||
@@ -49,10 +47,6 @@ export const useMediaLoaderWidget = (options: MediaLoaderOptions = {}) => {
|
||||
widgetValue.value = Array.isArray(value) ? value : []
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
// getMinHeight: () => (options.minHeight ?? 64) + PADDING,
|
||||
getMaxHeight: () => 225 + PADDING,
|
||||
getMinHeight: () => 176 + PADDING,
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true,
|
||||
|
||||
@@ -6,13 +6,8 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 16
|
||||
|
||||
export const useTextPreviewWidget = (
|
||||
options: {
|
||||
minHeight?: number
|
||||
} = {}
|
||||
) => {
|
||||
export const useTextPreviewWidget = () => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
@@ -28,7 +23,6 @@ export const useTextPreviewWidget = (
|
||||
setValue: (value: string | object) => {
|
||||
widgetValue.value = typeof value === 'string' ? value : String(value)
|
||||
},
|
||||
getMinHeight: () => options.minHeight ?? 42 + PADDING,
|
||||
serialize: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { type ComfyWidgetConstructorV2 } from '@/scripts/widgetTypes'
|
||||
|
||||
const PADDING = 8
|
||||
// Removed PADDING constant as it's no longer needed for CSS flexbox layout
|
||||
|
||||
export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
|
||||
const widgetConstructor: ComfyWidgetConstructorV2 = (
|
||||
@@ -40,11 +40,6 @@ export const useStringWidgetVue = (options: { defaultValue?: string } = {}) => {
|
||||
widgetValue.value = value
|
||||
},
|
||||
|
||||
// Optional: minimum height for the widget
|
||||
getMinHeight: () => {
|
||||
return inputSpec.multiline ? 80 + PADDING : 40 + PADDING
|
||||
},
|
||||
|
||||
// Optional: whether to serialize this widget's value
|
||||
serialize: true
|
||||
}
|
||||
|
||||
7
src/types/phantomNode.d.ts
vendored
Normal file
7
src/types/phantomNode.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Type extensions for phantom node functionality
|
||||
|
||||
declare module '@comfyorg/litegraph' {
|
||||
interface LGraphNode {
|
||||
phantom_mode?: boolean
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user