mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-07 00:20:07 +00:00
sync position, drag, re-render conditions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
59
src/composables/nodeRendering/useTestPhantomNodes.ts
Normal file
59
src/composables/nodeRendering/useTestPhantomNodes.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
@@ -255,6 +255,8 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
return
|
||||
}
|
||||
app.canvas.fitViewToSelectionAnimated()
|
||||
// Trigger re-render of Vue nodes after view change
|
||||
api.dispatchCustomEvent('graphChanged')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user