sync position, drag, re-render conditions

This commit is contained in:
bymyself
2025-06-11 12:50:11 -07:00
parent 7e153cf10b
commit 3166acb825
11 changed files with 724 additions and 230 deletions

View File

@@ -37,7 +37,7 @@
<SelectionOverlay v-if="selectionToolboxEnabled">
<SelectionToolbox />
</SelectionOverlay>
<DomWidgets />
<DomWidgets v-if="!vueNodeRenderingEnabled" />
<VueNodeOverlay v-if="vueNodeRenderingEnabled" />
</template>
<SubgraphBreadcrumb />
@@ -54,15 +54,16 @@ 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'
import VueNodeOverlay from '@/components/graph/nodes/VueNodeOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
import { useTestPhantomNodes } from '@/composables/nodeRendering/useTestPhantomNodes'
import { useCanvasDrop } from '@/composables/useCanvasDrop'
import { useContextMenuTranslation } from '@/composables/useContextMenuTranslation'
import { useCopy } from '@/composables/useCopy'
@@ -115,6 +116,17 @@ const selectionToolboxEnabled = computed(() =>
// Temporarily enable Vue node rendering for testing
const vueNodeRenderingEnabled = computed(() => true)
// Use test helper for automatic phantom mode enabling
useTestPhantomNodes()
// Debug logging
watchEffect(() => {
console.log(
'🖼 GraphCanvas: Vue node rendering enabled:',
vueNodeRenderingEnabled.value
)
})
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
@@ -281,6 +293,7 @@ onMounted(async () => {
useWorkflowAutoSave()
comfyApp.vueAppReady = true
console.log('🖼 GraphCanvas: comfyApp.vueAppReady:', comfyApp.vueAppReady)
workspaceStore.spinner = true
// ChangeTracker needs to be initialized before setup, as it will overwrite
@@ -314,6 +327,7 @@ onMounted(async () => {
window.graph = comfyApp.graph
comfyAppReady.value = true
console.log('🖼 GraphCanvas: comfyAppReady:', comfyAppReady.value)
comfyApp.canvas.onSelectionChange = useChainCallback(
comfyApp.canvas.onSelectionChange,

View File

@@ -1,50 +1,76 @@
<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"
ref="nodeRef"
class="_sb_node_preview vue-node"
: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 class="_sb_table">
<!-- Node header - exactly like NodePreview -->
<div
class="node_header"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR as string,
color: litegraphColors.NODE_TITLE_COLOR as string
}"
>
<div class="_sb_dot headdot" />
{{ (node as any).title }}
</div>
<!-- Node slot I/O - using flexbox for proper positioning -->
<div
v-for="[slotInput, slotOutput] in slotPairs"
:key="((slotInput as any)?.name || '') + ((slotOutput as any)?.name || '')"
class="slot-row-flex"
>
<!-- Left side input slot -->
<div class="slot-left" v-if="slotInput">
<div :class="['_sb_dot', (slotInput as any)?.type]" />
<span class="slot-text">{{ (slotInput as any)?.name }}</span>
</div>
<!-- Right side output slot -->
<div
class="slot-right"
v-if="slotOutput"
:style="{
color: litegraphColors.NODE_TEXT_COLOR as string
}"
>
<span class="slot-text">{{ (slotOutput as any)?.name }}</span>
<div :class="['_sb_dot', (slotOutput as any)?.type]" />
</div>
</div>
<!-- Widgets using existing widget components -->
<VueNodeBody
:widgets="nodeWidgets"
:node="node"
@widget-change="onWidgetChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import _ from 'lodash'
import { computed, ref } from 'vue'
import { useCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
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 { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useCanvasStore } from '@/stores/graphStore'
import VueNodeBody from './VueNodeBody.vue'
interface VueNodeProps {
node: LGraphNode
position?: NodePosition
selected: boolean
executing: boolean
canvasScale: number
canvasOffset: { x: number, y: number }
updateTrigger?: number // Add update trigger to force reactivity
}
const props = defineProps<VueNodeProps>()
@@ -53,34 +79,151 @@ 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,
}))
const nodeRef = ref<HTMLElement>()
const colorPaletteStore = useColorPaletteStore()
const canvasStore = useCanvasStore()
// 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
}))
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
)
// Get canvas position conversion utilities
const canvasPositionConversion = computed(() => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas?.canvas) return null
return useCanvasPositionConversion(lgCanvas.canvas, lgCanvas)
})
// Slot pairs - filter out inputs that have corresponding widgets
const slotPairs = computed(() => {
const allInputs = (props.node as any).inputs || []
const outputs = (props.node as any).outputs || []
// Get widget names to filter out inputs that have widgets
const nodeWidgetNames = new Set((props.node as any).widgets?.map((w: any) => w.name) || [])
// Only show inputs that DON'T have corresponding widgets
const slotInputs = allInputs.filter((input: any) => !nodeWidgetNames.has(input.name))
return _.zip(slotInputs, outputs)
})
// Extract widgets from the node
const nodeWidgets = computed(() => {
return props.node.widgets || []
return (props.node as any).widgets || []
})
// Dragging will be handled by LiteGraph's phantom node
// Node styling based on position and state - using proper canvas position conversion
const nodeStyle = computed(() => {
try {
// Access update trigger to make this reactive to graph changes
props.updateTrigger
const positionConverter = canvasPositionConversion.value
if (!positionConverter) {
console.warn('🚨 VueNode: No position converter available')
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
// Get node position and size in graph space
const nodeAny = props.node as any
const nodePos: [number, number] = [
nodeAny.pos?.[0] ?? 0,
nodeAny.pos?.[1] ?? 0
]
const nodeWidth = nodeAny.size?.[0] ?? 200
const nodeHeight = nodeAny.size?.[1] ?? 100
// Convert from canvas coordinates to client coordinates (absolute positioning)
const [clientX, clientY] = positionConverter.canvasPosToClientPos(nodePos)
// Get the current scale from the canvas
const lgCanvas = canvasStore.canvas
const scale = lgCanvas?.ds?.scale ?? 1
// Use original dimensions for positioning, apply scale via CSS transform
const scaledWidth = nodeWidth
const scaledHeight = nodeHeight
// Validate coordinates
if (!isFinite(clientX) || !isFinite(clientY) || scaledWidth <= 0 || scaledHeight <= 0) {
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
// Use colors from palette for authentic LiteGraph appearance
const nodeAnyForColors = props.node as any
const bgColor = nodeAnyForColors.bgcolor || litegraphColors.value?.NODE_DEFAULT_BGCOLOR || '#353535'
const borderColor = props.selected
? litegraphColors.value?.NODE_BOX_OUTLINE_COLOR || '#FFF'
: (nodeAnyForColors.boxcolor || litegraphColors.value?.NODE_DEFAULT_BOXCOLOR || '#666')
return {
position: 'fixed' as const, // Use fixed positioning like other overlays
left: `${clientX}px`,
top: `${clientY}px`,
minWidth: `${scaledWidth}px`,
width: 'auto', // Allow width to expand for content
minHeight: `${scaledHeight}px`,
transform: `scale(${scale})`,
transformOrigin: '0 0', // Scale from top-left corner
zIndex: props.selected ? 10 : 1,
backgroundColor: bgColor,
borderColor: borderColor,
borderWidth: props.selected ? '2px' : '1px',
borderStyle: 'solid',
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
}
} catch (error) {
return {
position: 'fixed' as const,
left: '100px',
top: '100px',
width: '200px',
minHeight: '100px',
backgroundColor: '#ff0000',
border: '2px solid #ffffff',
zIndex: 999
}
}
})
// Note: nodeClasses could be used for conditional CSS classes if needed
// Event handlers
const onMouseDown = (event: MouseEvent) => {
// Check if the click is on a widget element
const target = event.target as HTMLElement
const isOnWidget = target.closest('.widget-content') !== null
// If clicking on a widget, don't emit the mouse down event for dragging
if (isOnWidget) {
return
}
emit('interaction', {
type: 'mousedown',
nodeId: String(props.node.id),
nodeId: String((props.node as any).id),
originalEvent: event
})
}
@@ -88,55 +231,211 @@ const onMouseDown = (event: MouseEvent) => {
const onContextMenu = (event: MouseEvent) => {
emit('interaction', {
type: 'contextmenu',
nodeId: String(props.node.id),
nodeId: String((props.node as any).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
}
// Note: onSlotInteraction and onTitleEdit available for future use
const onWidgetChange = (widgetIndex: number, value: any) => {
if (props.node.widgets?.[widgetIndex]) {
props.node.widgets[widgetIndex].value = value
const nodeAny = props.node as any
if (nodeAny.widgets?.[widgetIndex]) {
nodeAny.widgets[widgetIndex].value = value
}
}
</script>
<style scoped>
/* Copy ALL styles from NodePreview.vue exactly */
.slot_row {
padding: 2px;
}
/* Original N-Sidebar styles */
._sb_dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: grey;
}
.node_header {
line-height: 1;
padding: 8px 13px 7px;
margin-bottom: 5px;
font-size: 15px;
text-wrap: nowrap;
overflow: hidden;
display: flex;
align-items: center;
}
.headdot {
width: 10px;
height: 10px;
float: inline-start;
margin-right: 8px;
}
.IMAGE {
background-color: #64b5f6;
}
.VAE {
background-color: #ff6e6e;
}
.LATENT {
background-color: #ff9cf9;
}
.MASK {
background-color: #81c784;
}
.CONDITIONING {
background-color: #ffa931;
}
.CLIP {
background-color: #ffd500;
}
.MODEL {
background-color: #b39ddb;
}
.CONTROL_NET {
background-color: #a5d6a7;
}
._sb_node_preview {
background-color: var(--comfy-menu-bg);
font-family: 'Open Sans', sans-serif;
font-size: small;
color: var(--descrip-text);
border: 1px solid var(--descrip-text);
min-width: 200px;
width: max-content; /* Allow expansion for wide content */
height: fit-content;
z-index: 9999;
border-radius: 12px;
overflow: visible; /* Allow content to be visible outside bounds */
font-size: 12px;
padding-bottom: 10px;
}
._sb_node_preview ._sb_description {
margin: 10px;
padding: 6px;
background: var(--border-color);
border-radius: 5px;
font-style: italic;
font-weight: 500;
font-size: 0.9rem;
word-break: break-word;
}
._sb_table {
display: grid;
grid-column-gap: 10px;
/* Spazio tra le colonne */
width: 100%;
/* Imposta la larghezza della tabella al 100% del contenitore */
}
._sb_row {
display: grid;
grid-template-columns: 10px 1fr 1fr 1fr 10px;
grid-column-gap: 10px;
align-items: center;
padding-left: 9px;
padding-right: 9px;
}
._sb_row_string {
grid-template-columns: 10px 1fr 1fr 10fr 1fr;
}
._sb_col {
border: 0 solid #000;
display: flex;
align-items: flex-end;
flex-direction: row-reverse;
flex-wrap: nowrap;
align-content: flex-start;
justify-content: flex-end;
}
._sb_inherit {
display: inherit;
}
._long_field {
background: var(--bg-color);
border: 2px solid var(--border-color);
margin: 5px 5px 0 5px;
border-radius: 10px;
line-height: 1.7;
text-wrap: nowrap;
}
._sb_arrow {
color: var(--fg-color);
}
._sb_preview_badge {
text-align: center;
background: var(--comfy-input-bg);
font-weight: bold;
color: var(--error-text);
}
/* Additional styles for Vue node functionality */
.vue-node {
transition: box-shadow 0.2s ease;
position: fixed; /* Use fixed positioning for proper overlay behavior */
pointer-events: none; /* Let mouse events pass through to phantom nodes */
}
.vue-node--selected {
@apply ring-2 ring-blue-500 shadow-xl;
.vue-node .widget-content {
pointer-events: auto; /* Enable interaction with widgets only */
}
.vue-node--executing {
@apply ring-2 ring-green-500;
animation: pulse 2s infinite;
.vue-node:hover {
z-index: 10000; /* Bring to front on hover */
}
.vue-node--collapsed {
@apply h-8;
.slot-text {
font-size: 10px; /* Smaller font for slot labels */
}
@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);
}
/* New flexbox slot layout */
.slot-row-flex {
position: relative;
min-height: 20px;
padding: 2px 0;
}
.slot-left {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
}
.slot-right {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
}
</style>

View File

@@ -1,55 +1,40 @@
<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"
<!-- Render Vue component widgets only -->
<div>
<div
v-for="widget in vueComponentWidgets"
:key="`vue-widget-${widget.name}`"
class="_sb_row _long_field"
>
<!-- 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 class="_sb_col widget-content">
<component
:is="widget.component"
:model-value="widget.value"
:widget="widget"
v-bind="widget.props"
v-if="widgetsShouldShow"
@update:model-value="updateWidgetValue(widget, $event)"
/>
</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'
import { computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { isComponentWidget } from '@/scripts/domWidget'
const widgetsShouldShow = ref(true)
app.api.addEventListener('graphChanged', () => {
widgetsShouldShow.value = app.canvas.ds.scale > .55
})
console.log('app.canvas.ds.scale', app.canvas.ds.scale)
interface VueNodeBodyProps {
widgets: BaseWidget[]
node: LGraphNode
@@ -57,48 +42,25 @@ interface VueNodeBodyProps {
const props = defineProps<VueNodeBodyProps>()
const emit = defineEmits<{
'widget-change': [widgetIndex: number, value: any]
}>()
// Note: emit available for future widget change events if needed
// 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 Vue component widgets only
const vueComponentWidgets = computed(() => {
return props.widgets.filter((widget: any) => isComponentWidget(widget))
})
// 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) + '...'
// Update widget value when component emits changes
const updateWidgetValue = (widget: any, value: any) => {
if (widget.options?.setValue) {
widget.options.setValue(value)
}
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], {})
// Also trigger the widget's callback if it exists
if (widget.callback) {
widget.callback(value)
}
}
// Note: onWidgetChange available for future use if needed
</script>
<style scoped>
@@ -114,4 +76,4 @@ const onWidgetChange = (index: number, value: any) => {
/* Styling for non-Vue widgets */
border: 1px dashed #ccc;
}
</style>
</style>

View File

@@ -1,48 +1,42 @@
<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="vue-node-header flex items-center justify-between px-3 py-2"
:style="headerStyle"
>
<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>
<!-- Collapse dot (like original LiteGraph) -->
<div
class="w-2.5 h-2.5 rounded-full cursor-pointer"
:style="{ backgroundColor: dotColor }"
@click="toggleCollapse"
/>
<!-- Editable title -->
<EditableText
v-model="editableTitle"
class="font-medium text-sm flex-grow"
class="font-medium flex-grow"
:style="titleStyle"
@update:model-value="onTitleUpdate"
/>
</div>
<!-- Node controls -->
<!-- Node controls (minimized to match LiteGraph style) -->
<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>
<!-- Pin indicator (small, unobtrusive) -->
<div
v-if="node.pinned"
class="w-2 h-2 rounded-full"
:style="{ backgroundColor: litegraphColors.NODE_TITLE_COLOR }"
title="Pinned"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, computed } from 'vue'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import EditableText from '@/components/common/EditableText.vue'
interface VueNodeHeaderProps {
@@ -57,6 +51,11 @@ const emit = defineEmits<{
'title-edit': [title: string]
}>()
const colorPaletteStore = useColorPaletteStore()
const litegraphColors = computed(
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
)
// Local editable title
const editableTitle = ref(props.title)
@@ -65,6 +64,60 @@ watch(() => props.title, (newTitle) => {
editableTitle.value = newTitle
})
// Header styling to match LiteGraph
const headerStyle = computed(() => {
try {
const headerColor = props.node.color || litegraphColors.value?.NODE_DEFAULT_COLOR || '#333'
return {
backgroundColor: headerColor,
borderTopLeftRadius: '4px',
borderTopRightRadius: '4px',
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
}
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in headerStyle:', error)
return {
backgroundColor: '#333',
borderTopLeftRadius: '4px',
borderTopRightRadius: '4px',
fontSize: '14px',
}
}
})
// Title styling to match LiteGraph
const titleStyle = computed(() => {
try {
const selected = (props.node as any).selected || false
const titleColor = selected
? litegraphColors.value?.NODE_SELECTED_TITLE_COLOR || '#FFF'
: litegraphColors.value?.NODE_TITLE_COLOR || '#999'
return {
color: titleColor,
fontSize: `${litegraphColors.value?.NODE_TEXT_SIZE || 14}px`,
fontWeight: 'normal',
}
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in titleStyle:', error)
return {
color: '#999',
fontSize: '14px',
fontWeight: 'normal',
}
}
})
// Dot color (collapse indicator)
const dotColor = computed(() => {
try {
return litegraphColors.value?.NODE_TITLE_COLOR || '#999'
} catch (error) {
console.warn('⚠️ VueNodeHeader: Error in dotColor:', error)
return '#999'
}
})
const onTitleUpdate = (newTitle: string) => {
emit('title-edit', newTitle)
}
@@ -80,18 +133,6 @@ const toggleCollapse = () => {
// 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>

View File

@@ -1,55 +1,139 @@
<template>
<div
<div
class="vue-node-overlay absolute inset-0 pointer-events-none overflow-hidden"
:style="overlayStyle"
>
<VueNode
v-for="node in visibleNodes"
v-for="node in phantomNodes"
:key="node.id"
:node="node"
:position="nodePositions[node.id]"
:selected="isNodeSelected(node.id)"
:executing="isNodeExecuting(node.id)"
:canvas-scale="canvasScale"
:canvas-offset="canvasOffset"
:update-trigger="graphUpdateTrigger"
@interaction="handleNodeInteraction"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useNodePositionSync } from '@/composables/nodeRendering/useNodePositionSync'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { useNodeInteractionProxy } from '@/composables/nodeRendering/useNodeInteractionProxy'
import { useCanvasStore } from '@/stores/graphStore'
import { api } from '@/scripts/api'
import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
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'
}))
// Reactive trigger for graph changes
const graphUpdateTrigger = ref(0)
// Force update phantom nodes when graph changes
const forceUpdate = () => {
graphUpdateTrigger.value++
}
// Get phantom nodes directly from canvas with reactive trigger
const phantomNodes = computed(() => {
// Access reactive trigger to ensure computed re-runs on graph changes
graphUpdateTrigger.value
if (!canvasStore.canvas?.graph) {
return []
}
const allNodes = canvasStore.canvas.graph._nodes
const phantomNodes = allNodes.filter(
(node: any) => node.phantom_mode === true
)
// Register widgets for phantom nodes if not already registered
const domWidgetStore = useDomWidgetStore()
phantomNodes.forEach((node: any) => {
if (node.widgets) {
node.widgets.forEach((widget: any) => {
// Check if it's a DOM widget that needs registration
if (
(isDOMWidget(widget) || isComponentWidget(widget)) &&
widget.id &&
!domWidgetStore.widgetStates.has(widget.id)
) {
domWidgetStore.registerWidget(widget)
}
})
}
})
return phantomNodes
})
// Simple animation frame updates - always running for smooth dragging
let rafId: number | null = null
const startFrameUpdates = () => {
const updateEveryFrame = () => {
forceUpdate()
rafId = requestAnimationFrame(updateEveryFrame)
}
updateEveryFrame()
}
const stopFrameUpdates = () => {
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
}
// Listen for graph changes
onMounted(() => {
// Listen to API events for graph changes (now includes ds changes)
api.addEventListener('graphChanged', forceUpdate)
// Start continuous frame updates for smooth dragging
startFrameUpdates()
// Initial update
forceUpdate()
})
onUnmounted(() => {
api.removeEventListener('graphChanged', forceUpdate)
stopFrameUpdates()
})
// Get canvas transform directly from canvas
const canvasScale = computed(() => {
return canvasStore.canvas?.ds?.scale || 1
})
const canvasOffset = computed(() => {
const canvas = canvasStore.canvas
return {
x: canvas?.ds?.offset?.[0] || 0,
y: canvas?.ds?.offset?.[1] || 0
}
})
// Check if node is selected
const isNodeSelected = (nodeId: string) => {
return canvasStore.selectedItems.has(Number(nodeId))
return canvasStore.selectedItems.some(
(item: any) => item.id === Number(nodeId)
)
}
// Check if node is executing
const isNodeExecuting = (nodeId: string) => {
return executionStore.executingNodeId === Number(nodeId)
}
</script>
<style scoped>
@@ -57,4 +141,4 @@ const isNodeExecuting = (nodeId: string) => {
/* Ensure overlay doesn't interfere with canvas interactions */
z-index: 1;
}
</style>
</style>

View File

@@ -86,16 +86,27 @@ export function useNodePositionSync() {
// Get visible nodes (within viewport bounds)
const visibleNodes = computed(() => {
if (!canvas.value?.graph) return []
if (!canvas.value?.graph) {
console.log('🚫 useNodePositionSync: No canvas or graph available')
return []
}
const nodes = canvas.value.graph._nodes.filter((node: LGraphNode) => {
// Only return nodes that have phantom_mode enabled
return node.phantom_mode === true
const allNodes = canvas.value.graph._nodes
console.log('🔍 useNodePositionSync: Checking', allNodes.length, 'total nodes')
const phantomNodes = allNodes.filter((node: LGraphNode) => {
const isPhantom = node.phantom_mode === true
if (isPhantom) {
console.log('👻 Found phantom node:', { id: node.id, title: node.title, phantom_mode: node.phantom_mode })
}
return isPhantom
})
console.log('📊 useNodePositionSync: Found', phantomNodes.length, 'phantom nodes out of', allNodes.length, 'total')
// TODO: Add viewport culling for performance
// For now, return all phantom nodes
return nodes
return phantomNodes
})
// Manual sync function for external triggers

View File

@@ -0,0 +1,59 @@
import { onMounted, onUnmounted } from 'vue'
import { usePhantomNodes } from './usePhantomNodes'
import { useCanvasStore } from '@/stores/graphStore'
import { api } from '@/scripts/api'
/**
* Development helper to automatically enable phantom mode for testing
*/
export function useTestPhantomNodes() {
const { enableAllPhantomMode, getPhantomNodes } = usePhantomNodes()
const canvasStore = useCanvasStore()
let graphChangeHandler: (() => void) | null = null
onMounted(() => {
// Function to enable phantom mode for all nodes
const enablePhantomModeForAllNodes = () => {
if (canvasStore.canvas?.graph) {
const count = enableAllPhantomMode()
if (count > 0) {
console.log(`✅ Enabled phantom mode for ${count} nodes`)
}
return count
}
return 0
}
// Listen for graph changes to immediately enable phantom mode for new nodes
graphChangeHandler = () => {
enablePhantomModeForAllNodes()
}
api.addEventListener('graphChanged', graphChangeHandler)
// Initial attempt when mounted
setTimeout(() => {
enablePhantomModeForAllNodes()
}, 100) // Much shorter timeout just to ensure canvas is ready
})
onUnmounted(() => {
if (graphChangeHandler) {
api.removeEventListener('graphChanged', graphChangeHandler)
}
})
// Expose helper functions to global scope for manual testing
if (typeof window !== 'undefined') {
(window as any).testPhantomNodes = {
enableAll: enableAllPhantomMode,
getPhantom: getPhantomNodes,
enableSingle: (nodeId: string) => {
const { enablePhantomMode } = usePhantomNodes()
return enablePhantomMode(nodeId)
}
}
console.log('🚀 Phantom node testing helpers available on window.testPhantomNodes')
}
}

View File

@@ -255,6 +255,8 @@ export function useCoreCommands(): ComfyCommand[] {
return
}
app.canvas.fitViewToSelectionAnimated()
// Trigger re-render of Vue nodes after view change
api.dispatchCustomEvent('graphChanged')
}
},
{

View File

@@ -1150,6 +1150,7 @@ export class ComfyApp {
}
useExtensionService().invokeExtensions('loadedGraphNode', node)
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
}
if (missingNodeTypes.length && showMissingNodesDialog) {
@@ -1169,6 +1170,7 @@ export class ComfyApp {
)
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true)
api.dispatchCustomEvent('graphChanged', { workflow: this.graph.serialize() as unknown as ComfyWorkflowJSON })
})
}

View File

@@ -271,6 +271,27 @@ export class ChangeTracker {
return v
}
// Handle wheel events (zoom/pan with mouse wheel)
const processMouseWheel = LGraphCanvas.prototype.processMouseWheel
LGraphCanvas.prototype.processMouseWheel = function (e) {
const v = processMouseWheel.apply(this, [e])
logger.debug('checkState on processMouseWheel')
checkState()
return v
}
// Handle drag events (panning)
const processMouseMove = LGraphCanvas.prototype.processMouseMove
LGraphCanvas.prototype.processMouseMove = function (e) {
const v = processMouseMove.apply(this, [e])
// Only check state if we're dragging the canvas (not a node)
if (this.dragging_canvas) {
logger.debug('checkState on processMouseMove (canvas drag)')
checkState()
}
return v
}
// Handle litegraph dialog popup for number/string widgets
const prompt = LGraphCanvas.prototype.prompt
LGraphCanvas.prototype.prompt = function (
@@ -369,10 +390,8 @@ export class ChangeTracker {
return false
}
// Compare extra properties ignoring ds
if (
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
)
// Compare extra properties including ds for Vue node position updates
if (!_.isEqual(a.extra ?? {}, b.extra ?? {}))
return false
// Compare other properties normally

View File

@@ -241,7 +241,8 @@ export class ComponentWidgetImpl<
}) {
super({
...obj,
type: 'custom'
type: 'custom',
options: { hideOnZoom: true, ...obj.options }
})
this.component = obj.component
this.inputSpec = obj.inputSpec