Merge remote-tracking branch 'origin/main' into bl-more-slots

This commit is contained in:
Benjamin Lu
2025-09-28 00:06:55 -07:00
40 changed files with 454 additions and 549 deletions

View File

@@ -1,7 +1,7 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,13 +1,13 @@
import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier'
import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../helpers/fitToView'
} from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox()
@@ -189,6 +189,13 @@ test.describe('Vue Node Link Interaction', () => {
expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.getLinkCount()).toBe(0)
const graphLinkDetails = await getInputLinkDetails(
comfyPage.page,
clipNode.id,
0
)
expect(graphLinkDetails).toBeNull()
})
test('should not create a link when dropping onto a slot on the same node', async ({
@@ -218,7 +225,6 @@ test.describe('Vue Node Link Interaction', () => {
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter(
comfyPage.page,
samplerNode.id,

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

View File

@@ -0,0 +1,76 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../../fixtures/utils/vueNodeFixtures'
test.describe('Vue Nodes Renaming', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('should display node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('should allow title renaming by double clicking on the node header', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
await expect(editingTitleInput).not.toBeVisible()
})
})

View File

@@ -1,7 +1,7 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
} from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,7 +15,8 @@ test.describe('Vue Node Selection', () => {
const modifiers = [
{ key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' }
{ key: 'Shift', name: 'shift' },
{ key: 'Meta', name: 'meta' }
] as const
for (const { key: modifier, name } of modifiers) {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,80 +1,20 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures'
} from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
test.describe('NodeHeader', () => {
test.describe('Vue Node Collapse', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
})
test('displays node title', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
expect(nodes.length).toBeGreaterThanOrEqual(1)
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
const title = await vueNode.getTitle()
expect(title).toBe('KSampler')
// Verify title is visible in the header
const header = await vueNode.getHeader()
await expect(header).toContainText('KSampler')
})
test('allows title renaming', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
// Test renaming with Enter
await vueNode.setTitle('My Custom Sampler')
const newTitle = await vueNode.getTitle()
expect(newTitle).toBe('My Custom Sampler')
// Verify the title is displayed
const header = await vueNode.getHeader()
await expect(header).toContainText('My Custom Sampler')
// Test cancel with Escape
const titleElement = await vueNode.getTitleElement()
await titleElement.dblclick()
await comfyPage.nextFrame()
// Type a different value but cancel
const input = (await vueNode.getHeader()).locator(
'[data-testid="node-title-input"]'
)
await input.fill('This Should Be Cancelled')
await input.press('Escape')
await comfyPage.nextFrame()
// Title should remain as the previously saved value
const titleAfterCancel = await vueNode.getTitle()
expect(titleAfterCancel).toBe('My Custom Sampler')
})
test('Double click node body does not trigger edit', async ({
test('should allow collapsing node with collapse icon', async ({
comfyPage
}) => {
const loadCheckpointNode =
comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
const nodeBbox = await loadCheckpointNode.boundingBox()
if (!nodeBbox) throw new Error('Node not found')
await loadCheckpointNode.dblclick()
const editingTitleInput = comfyPage.page.getByTestId('node-title-input')
await expect(editingTitleInput).not.toBeVisible()
})
test('handles node collapsing', async ({ comfyPage }) => {
// Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
@@ -103,7 +43,7 @@ test.describe('NodeHeader', () => {
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height)
})
test('shows collapse/expand icon state', async ({ comfyPage }) => {
test('should show collapse/expand icon state', async ({ comfyPage }) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)
@@ -123,7 +63,9 @@ test.describe('NodeHeader', () => {
expect(iconClass).toContain('pi-chevron-down')
})
test('preserves title when collapsing/expanding', async ({ comfyPage }) => {
test('should preserve title when collapsing/expanding', async ({
comfyPage
}) => {
const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page)

View File

@@ -0,0 +1,49 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../fixtures/ComfyPage'
test.describe('Vue Node Custom Colors', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('displays color picker button and allows color selection', async ({
comfyPage
}) => {
const loadCheckpointNode = comfyPage.page.locator('[data-node-id]').filter({
hasText: 'Load Checkpoint'
})
await loadCheckpointNode.getByText('Load Checkpoint').click()
await comfyPage.page.locator('.selection-toolbox .pi-circle-fill').click()
await comfyPage.page
.locator('.color-picker-container')
.locator('i[data-testid="blue"]')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-color-blue.png'
)
})
test('should load node colors from workflow', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-dark-all-colors.png'
)
})
test('should show brightened node colors on light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.loadWorkflow('nodes/every_node_color')
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-custom-colors-light-all-colors.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.28.2",
"version": "1.28.3",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -158,4 +158,4 @@
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
}
}
}

View File

@@ -93,6 +93,7 @@ import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vu
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -103,7 +104,6 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -189,8 +189,8 @@ watch(
}
)
const allNodes = computed(() =>
Array.from(vueNodeLifecycle.vueNodeData.value.values())
const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
)
watchEffect(() => {
@@ -225,7 +225,6 @@ watch(
for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue
for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w)
if (w.linkedWidgets) {
@@ -293,45 +292,36 @@ watch(
{ deep: true }
)
// Update node slot errors
// Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch(
() => executionStore.lastNodeErrors,
(lastNodeErrors) => {
const removeSlotError = (node: LGraphNode) => {
if (!comfyApp.graph) return
for (const node of comfyApp.graph.nodes) {
// Clear existing errors
for (const slot of node.inputs) {
delete slot.hasErrors
}
for (const slot of node.outputs) {
delete slot.hasErrors
}
}
for (const node of comfyApp.graph.nodes) {
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue
const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined
)
const slotErrorsChanged =
validErrors.length > 0 &&
validErrors.some((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
return true
}
return false
})
// Trigger Vue node data update if slot errors changed
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
comfyApp.graph.onTrigger('node:slot-errors:changed', {
nodeId: node.id
})
}
validErrors.forEach((error) => {
const inputName = error.extra_info!.input_name!
const inputIndex = node.findInputSlot(inputName)
if (inputIndex !== -1) {
node.inputs[inputIndex].hasErrors = true
}
})
}
comfyApp.canvas.draw(true, true)
@@ -364,7 +354,6 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()

View File

@@ -41,6 +41,8 @@ export interface VueNodeData {
collapsed?: boolean
pinned?: boolean
}
color?: string
bgcolor?: string
}
export interface GraphNodeManager {
@@ -126,7 +128,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : undefined,
flags: node.flags ? { ...node.flags } : undefined
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined
}
}
@@ -449,6 +453,24 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
...currentData,
mode: typeof event.newValue === 'number' ? event.newValue : 0
})
break
case 'color':
vueNodeData.set(nodeId, {
...currentData,
color:
typeof event.newValue === 'string'
? event.newValue
: undefined
})
break
case 'bgcolor':
vueNodeData.set(nodeId, {
...currentData,
bgcolor:
typeof event.newValue === 'string'
? event.newValue
: undefined
})
}
}
} else if (

View File

@@ -1,11 +1,8 @@
import { createSharedComposable } from '@vueuse/core'
import { readonly, ref, shallowRef, watch } from 'vue'
import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type {
GraphNodeManager,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -22,31 +19,19 @@ function useVueNodeLifecycleIndividual() {
const { shouldRenderVueNodes } = useVueFeatureFlags()
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
// Sync management
const slotSync = shallowRef<ReturnType<typeof useSlotLayoutSync> | null>(null)
const slotSyncStarted = ref(false)
const linkSync = shallowRef<ReturnType<typeof useLinkLayoutSync> | null>(null)
// Vue node data state
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
// Trigger for forcing computed re-evaluation
const nodeDataTrigger = ref(0)
const { startSync } = useLayoutSync()
const linkSyncManager = useLinkLayoutSync()
const slotSyncManager = useSlotLayoutSync()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
const activeGraph = comfyApp.canvas?.graph
if (!activeGraph || nodeManager.value) return
// Initialize the core node manager
const manager = useGraphNodeManager(activeGraph)
nodeManager.value = manager
cleanupNodeManager.value = manager.cleanup
// Use the manager's data maps
vueNodeData.value = manager.vueNodeData
// Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
@@ -76,46 +61,29 @@ function useVueNodeLifecycleIndividual() {
}
// Initialize layout sync (one-way: Layout Store → LiteGraph)
const { startSync } = useLayoutSync()
startSync(canvasStore.canvas)
// Initialize link layout sync for event-driven updates
const linkSyncManager = useLinkLayoutSync()
linkSync.value = linkSyncManager
if (comfyApp.canvas) {
linkSyncManager.start(comfyApp.canvas)
}
// Force computed properties to re-evaluate
nodeDataTrigger.value++
}
const disposeNodeManagerAndSyncs = () => {
if (!nodeManager.value) return
try {
cleanupNodeManager.value?.()
nodeManager.value.cleanup()
} catch {
/* empty */
}
nodeManager.value = null
cleanupNodeManager.value = null
// Clean up link layout sync
if (linkSync.value) {
linkSync.value.stop()
linkSync.value = null
}
// Reset reactive maps to clean state
vueNodeData.value = new Map()
linkSyncManager.stop()
}
// Watch for Vue nodes enabled state changes
watch(
() =>
shouldRenderVueNodes.value &&
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
() => shouldRenderVueNodes.value && Boolean(comfyApp.canvas?.graph),
(enabled) => {
if (enabled) {
initializeNodeManager()
@@ -138,20 +106,14 @@ function useVueNodeLifecycleIndividual() {
}
// Switching to Vue
if (vueMode && slotSyncStarted.value) {
slotSync.value?.stop()
slotSyncStarted.value = false
if (vueMode) {
slotSyncManager.stop()
}
// Switching to LG
const shouldRun = Boolean(canvas?.graph) && !vueMode
if (shouldRun && !slotSyncStarted.value && canvas) {
// Initialize slot sync if not already created
if (!slotSync.value) {
slotSync.value = useSlotLayoutSync()
}
const started = slotSync.value.attemptStart(canvas as LGraphCanvas)
slotSyncStarted.value = started
if (shouldRun && canvas) {
slotSyncManager.attemptStart(canvas as LGraphCanvas)
}
},
{ immediate: true }
@@ -159,26 +121,27 @@ function useVueNodeLifecycleIndividual() {
// Handle case where Vue nodes are enabled but graph starts empty
const setupEmptyGraphListener = () => {
const activeGraph = comfyApp.canvas?.graph
if (
shouldRenderVueNodes.value &&
comfyApp.graph &&
!nodeManager.value &&
comfyApp.graph._nodes.length === 0
!shouldRenderVueNodes.value ||
nodeManager.value ||
activeGraph?._nodes.length !== 0
) {
const originalOnNodeAdded = comfyApp.graph.onNodeAdded
comfyApp.graph.onNodeAdded = function (node: LGraphNode) {
// Restore original handler
comfyApp.graph.onNodeAdded = originalOnNodeAdded
return
}
const originalOnNodeAdded = activeGraph.onNodeAdded
activeGraph.onNodeAdded = function (node: LGraphNode) {
// Restore original handler
activeGraph.onNodeAdded = originalOnNodeAdded
// Initialize node manager if needed
if (shouldRenderVueNodes.value && !nodeManager.value) {
initializeNodeManager()
}
// Initialize node manager if needed
if (shouldRenderVueNodes.value && !nodeManager.value) {
initializeNodeManager()
}
// Call original handler
if (originalOnNodeAdded) {
originalOnNodeAdded.call(this, node)
}
// Call original handler
if (originalOnNodeAdded) {
originalOnNodeAdded.call(this, node)
}
}
}
@@ -189,20 +152,12 @@ function useVueNodeLifecycleIndividual() {
nodeManager.value.cleanup()
nodeManager.value = null
}
if (slotSyncStarted.value) {
slotSync.value?.stop()
slotSyncStarted.value = false
}
slotSync.value = null
if (linkSync.value) {
linkSync.value.stop()
linkSync.value = null
}
slotSyncManager.stop()
linkSyncManager.stop()
}
return {
vueNodeData,
nodeManager: readonly(nodeManager),
nodeManager,
// Lifecycle methods
initializeNodeManager,

View File

@@ -14,7 +14,7 @@ import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement>) => {
export const useCanvasDrop = (canvasRef: Ref<HTMLCanvasElement | null>) => {
const modelToNodeStore = useModelToNodeStore()
const litegraphService = useLitegraphService()
const workflowService = useWorkflowService()

View File

@@ -2,19 +2,17 @@ import {
draggable,
dropTargetForElements
} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { onBeforeUnmount, onMounted } from 'vue'
import { toValue } from 'vue'
import { type MaybeRefOrGetter, onBeforeUnmount, onMounted } from 'vue'
export function usePragmaticDroppable(
dropTargetElement: HTMLElement | (() => HTMLElement),
dropTargetElement: MaybeRefOrGetter<HTMLElement | null>,
options: Omit<Parameters<typeof dropTargetForElements>[0], 'element'>
) {
let cleanup = () => {}
onMounted(() => {
const element =
typeof dropTargetElement === 'function'
? dropTargetElement()
: dropTargetElement
const element = toValue(dropTargetElement)
if (!element) {
return
@@ -32,16 +30,13 @@ export function usePragmaticDroppable(
}
export function usePragmaticDraggable(
draggableElement: HTMLElement | (() => HTMLElement),
draggableElement: MaybeRefOrGetter<HTMLElement | null>,
options: Omit<Parameters<typeof draggable>[0], 'element'>
) {
let cleanup = () => {}
onMounted(() => {
const element =
typeof draggableElement === 'function'
? draggableElement()
: draggableElement
const element = toValue(draggableElement)
if (!element) {
return
@@ -51,6 +46,7 @@ export function usePragmaticDraggable(
element,
...options
})
// TODO: Change to onScopeDispose
})
onBeforeUnmount(() => {

View File

@@ -170,7 +170,7 @@ class GroupNodeBuilder {
// Use the built in copyToClipboard function to generate the node data we need
try {
// @ts-expect-error fixme ts strict error
const serialised = serialise(this.nodes, app.canvas.graph)
const serialised = serialise(this.nodes, app.canvas?.graph)
const config = JSON.parse(serialised)
storeLinkTypes(config)

View File

@@ -757,9 +757,7 @@ export class LGraphCanvas
// Initialize link renderer if graph is available
if (graph) {
this.linkRenderer = new LitegraphLinkAdapter(graph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
this.linkRenderer = new LitegraphLinkAdapter(false)
}
this.linkConnector.events.addEventListener('link-created', () =>
@@ -1858,9 +1856,7 @@ export class LGraphCanvas
newGraph.attachCanvas(this)
// Re-initialize link renderer with new graph
this.linkRenderer = new LitegraphLinkAdapter(newGraph)
// Disable layout writes during render
this.linkRenderer.enableLayoutStoreWrites = false
this.linkRenderer = new LitegraphLinkAdapter(false)
this.dispatch('litegraph:set-graph', { newGraph, oldGraph: graph })
this.#dirty()

View File

@@ -7,7 +7,9 @@ const DEFAULT_TRACKED_PROPERTIES: string[] = [
'title',
'flags.collapsed',
'flags.pinned',
'mode'
'mode',
'color',
'bgcolor'
]
/**

View File

@@ -251,6 +251,8 @@ export interface IBaseWidget<
TType extends string = string,
TOptions extends IWidgetOptions<unknown> = IWidgetOptions<unknown>
> {
[symbol: symbol]: boolean
linkedWidgets?: IBaseWidget[]
name: string

View File

@@ -116,7 +116,7 @@ export const useCanvasStore = defineStore('canvas', () => {
newCanvas.canvas,
'litegraph:set-graph',
(event: CustomEvent<{ newGraph: LGraph; oldGraph: LGraph }>) => {
const newGraph = event.detail?.newGraph || app.canvas?.graph
const newGraph = event.detail?.newGraph ?? app.canvas?.graph // TODO: Ambiguous Graph
currentGraph.value = newGraph
isInSubgraph.value = Boolean(app.canvas?.subgraph)
}

View File

@@ -6,7 +6,6 @@
* rendering data that can be consumed by the PathRenderer.
* Maintains backward compatibility with existing litegraph integration.
*/
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type {
@@ -19,7 +18,6 @@ import {
LinkMarkerShape,
LinkRenderType
} from '@/lib/litegraph/src/types/globalEnums'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import {
type ArrowShape,
CanvasPathRenderer,
@@ -54,142 +52,10 @@ export interface LinkRenderContext {
disabledPattern?: CanvasPattern | null
}
interface LinkRenderOptions {
color?: CanvasColour
flow?: boolean
skipBorder?: boolean
disabled?: boolean
}
export class LitegraphLinkAdapter {
private graph: LGraph
private pathRenderer: CanvasPathRenderer
public enableLayoutStoreWrites = true
private readonly pathRenderer = new CanvasPathRenderer()
constructor(graph: LGraph) {
this.graph = graph
this.pathRenderer = new CanvasPathRenderer()
}
/**
* Render a single link with all necessary data properly fetched
* Populates link.path for hit detection
*/
renderLink(
ctx: CanvasRenderingContext2D,
link: LLink,
context: LinkRenderContext,
options: LinkRenderOptions = {}
): void {
// Get nodes from graph
const sourceNode = this.graph.getNodeById(link.origin_id)
const targetNode = this.graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) {
console.warn(`Cannot render link ${link.id}: missing nodes`)
return
}
// Get slots from nodes
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) {
console.warn(`Cannot render link ${link.id}: missing slots`)
return
}
// Get positions using layout tree data if available
const startPos = getSlotPosition(
sourceNode,
link.origin_slot,
false // output
)
const endPos = getSlotPosition(
targetNode,
link.target_slot,
true // input
)
// Get directions from slots
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Convert to pure render data
const linkData = this.convertToLinkRenderData(
link,
{ x: startPos[0], y: startPos[1] },
{ x: endPos[0], y: endPos[1] },
startDir,
endDir,
options
)
// Convert context
const pathContext = this.convertToPathRenderContext(context)
// Render using pure renderer
const path = this.pathRenderer.drawLink(ctx, linkData, pathContext)
// Store path for hit detection
link.path = path
// Update layout store when writes are enabled (event-driven path)
if (this.enableLayoutStoreWrites && link.id !== -1) {
// Calculate bounds and center only when writing
const bounds = this.calculateLinkBounds(startPos, endPos, linkData)
const centerPos = linkData.centerPos || {
x: (startPos[0] + endPos[0]) / 2,
y: (startPos[1] + endPos[1]) / 2
}
layoutStore.updateLinkLayout(link.id, {
id: link.id,
path: path,
bounds: bounds,
centerPos: centerPos,
sourceNodeId: String(link.origin_id),
targetNodeId: String(link.target_id),
sourceSlot: link.origin_slot,
targetSlot: link.target_slot
})
// Also update segment layout for the whole link (null rerouteId means final segment)
layoutStore.updateLinkSegmentLayout(link.id, null, {
path: path,
bounds: bounds,
centerPos: centerPos
})
}
}
/**
* Convert litegraph link data to pure render format
*/
private convertToLinkRenderData(
link: LLink,
startPoint: Point,
endPoint: Point,
startDir: LinkDirection,
endDir: LinkDirection,
options: LinkRenderOptions
): LinkRenderData {
return {
id: String(link.id),
startPoint,
endPoint,
startDirection: this.convertDirection(startDir),
endDirection: this.convertDirection(endDir),
color: options.color
? String(options.color)
: link.color
? String(link.color)
: undefined,
type: link.type !== undefined ? String(link.type) : undefined,
flow: options.flow || false,
disabled: options.disabled || false
}
}
constructor(public readonly enableLayoutStoreWrites = true) {}
/**
* Convert LinkDirection enum to Direction string

View File

@@ -5,7 +5,9 @@
* The layout store is the single source of truth.
*/
import { onUnmounted } from 'vue'
import { ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
/**
@@ -13,27 +15,27 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
* This replaces the bidirectional sync with a one-way sync
*/
export function useLayoutSync() {
let unsubscribe: (() => void) | null = null
const unsubscribe = ref<() => void>()
/**
* Start syncing from Layout system to LiteGraph
* This is one-way: Layout → LiteGraph only
* Start syncing from Layout LiteGraph
*/
function startSync(canvas: any) {
function startSync(canvas: ReturnType<typeof useCanvasStore>['canvas']) {
if (!canvas?.graph) return
// Cancel last subscription
stopSync()
// Subscribe to layout changes
unsubscribe = layoutStore.onChange((change) => {
unsubscribe.value = layoutStore.onChange((change) => {
// Apply changes to LiteGraph regardless of source
// The layout store is the single source of truth
for (const nodeId of change.nodeIds) {
const layout = layoutStore.getNodeLayoutRef(nodeId).value
if (!layout) continue
const liteNode = canvas.graph.getNodeById(parseInt(nodeId))
const liteNode = canvas.graph?.getNodeById(parseInt(nodeId))
if (!liteNode) continue
// Update position if changed
if (
liteNode.pos[0] !== layout.position.x ||
liteNode.pos[1] !== layout.position.y
@@ -42,7 +44,6 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
// Update size if changed
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
@@ -57,20 +58,12 @@ export function useLayoutSync() {
})
}
/**
* Stop syncing
*/
function stopSync() {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
unsubscribe.value?.()
unsubscribe.value = undefined
}
// Auto-cleanup on unmount
onUnmounted(() => {
stopSync()
})
onUnmounted(stopSync)
return {
startSync,

View File

@@ -1,14 +1,6 @@
/**
* Composable for event-driven link layout synchronization
*
* Implements event-driven link layout updates decoupled from the render cycle.
* Updates link geometry only when it actually changes (node move/resize, link create/delete,
* reroute create/delete/move, collapse toggles).
*/
import log from 'loglevel'
import { onUnmounted } from 'vue'
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, ref, toValue } from 'vue'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
@@ -20,23 +12,17 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { LayoutChange } from '@/renderer/core/layout/types'
const logger = log.getLogger('useLinkLayoutSync')
/**
* Composable for managing link layout synchronization
*/
export function useLinkLayoutSync() {
let canvas: LGraphCanvas | null = null
let graph: LGraph | null = null
let offscreenCtx: CanvasRenderingContext2D | null = null
let adapter: LitegraphLinkAdapter | null = null
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
const canvasRef = ref<LGraphCanvas>()
const graphRef = computed(() => canvasRef.value?.graph)
const unsubscribeLayoutChange = ref<() => void>()
const adapter = new LitegraphLinkAdapter()
/**
* Build link render context from canvas properties
*/
function buildLinkRenderContext(): LinkRenderContext {
const canvas = toValue(canvasRef)
if (!canvas) {
throw new Error('Canvas not initialized')
}
@@ -73,7 +59,9 @@ export function useLinkLayoutSync() {
* - No dragging state handling (pure geometry computation)
*/
function recomputeLinkById(linkId: number): void {
if (!graph || !adapter || !offscreenCtx || !canvas) return
const canvas = toValue(canvasRef)
const graph = toValue(graphRef)
if (!graph || !canvas) return
const link = graph.links.get(linkId)
if (!link || link.id === -1) return // Skip floating/temp links
@@ -131,7 +119,7 @@ export function useLinkLayoutSync() {
// Render segment to this reroute
adapter.renderLinkDirect(
offscreenCtx,
canvas.ctx,
segmentStartPos,
reroute.pos,
link,
@@ -167,7 +155,7 @@ export function useLinkLayoutSync() {
]
adapter.renderLinkDirect(
offscreenCtx,
canvas.ctx,
lastReroute.pos,
endPos,
link,
@@ -185,7 +173,7 @@ export function useLinkLayoutSync() {
} else {
// No reroutes - render direct link
adapter.renderLinkDirect(
offscreenCtx,
canvas.ctx,
startPos,
endPos,
link,
@@ -206,6 +194,7 @@ export function useLinkLayoutSync() {
* Recompute all links connected to a node
*/
function recomputeLinksForNode(nodeId: number): void {
const graph = toValue(graphRef)
if (!graph) return
const node = graph.getNodeById(nodeId)
@@ -243,6 +232,7 @@ export function useLinkLayoutSync() {
* Recompute all links associated with a reroute
*/
function recomputeLinksForReroute(rerouteId: number): void {
const graph = toValue(graphRef)
if (!graph) return
const reroute = graph.reroutes.get(rerouteId)
@@ -258,105 +248,55 @@ export function useLinkLayoutSync() {
* Start link layout sync with event-driven functionality
*/
function start(canvasInstance: LGraphCanvas): void {
canvas = canvasInstance
graph = canvas.graph
if (!graph) return
// Create offscreen canvas context
const offscreenCanvas = document.createElement('canvas')
offscreenCtx = offscreenCanvas.getContext('2d')
if (!offscreenCtx) {
logger.error('Failed to create offscreen canvas context')
return
}
// Create dedicated adapter with layout writes enabled
adapter = new LitegraphLinkAdapter(graph)
adapter.enableLayoutStoreWrites = true
canvasRef.value = canvasInstance
if (!canvasInstance.graph) return
// Initial computation for all existing links
for (const link of graph._links.values()) {
for (const link of canvasInstance.graph._links.values()) {
if (link.id !== -1) {
recomputeLinkById(link.id)
}
}
// Subscribe to layout store changes
unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => {
switch (change.operation.type) {
case 'moveNode':
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break
case 'deleteLink':
// No-op - store already cleaned by existing code
break
case 'createReroute':
case 'deleteReroute':
// Recompute all affected links
if ('linkIds' in change.operation) {
for (const linkId of change.operation.linkIds) {
recomputeLinkById(linkId)
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = layoutStore.onChange(
(change: LayoutChange) => {
switch (change.operation.type) {
case 'moveNode':
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'createLink':
recomputeLinkById(change.operation.linkId)
break
case 'deleteLink':
// No-op - store already cleaned by existing code
break
case 'createReroute':
case 'deleteReroute':
// Recompute all affected links
if ('linkIds' in change.operation) {
for (const linkId of change.operation.linkIds) {
recomputeLinkById(linkId)
}
}
}
break
case 'moveReroute':
recomputeLinksForReroute(change.operation.rerouteId)
break
}
})
// Hook collapse events
const origTrigger = graph.onTrigger
graph.onTrigger = (action: string, param: any) => {
if (
action === 'node:property:changed' &&
param?.property === 'flags.collapsed'
) {
const nodeId = parseInt(String(param.nodeId))
if (!isNaN(nodeId)) {
recomputeLinksForNode(nodeId)
break
case 'moveReroute':
recomputeLinksForReroute(change.operation.rerouteId)
break
}
}
if (origTrigger) {
origTrigger.call(graph, action, param)
}
}
// Store cleanup function
restoreHandlers = () => {
if (graph) {
graph.onTrigger = origTrigger || undefined
}
}
)
}
/**
* Stop link layout sync and cleanup all resources
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
canvas = null
graph = null
offscreenCtx = null
adapter = null
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = undefined
canvasRef.value = undefined
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
tryOnScopeDispose(stop)
return {
start,

View File

@@ -1,10 +1,5 @@
/**
* Composable for managing slot layout registration
*
* Implements event-driven slot registration decoupled from the draw cycle.
* Registers slots once on initial load and keeps them updated when necessary.
*/
import { onUnmounted } from 'vue'
import { tryOnScopeDispose } from '@vueuse/core'
import { ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -13,10 +8,6 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
/**
* Compute and register slot layouts for a node
* @param node LiteGraph node to process
*/
function computeAndRegisterSlots(node: LGraphNode): void {
const nodeId = String(node.id)
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
@@ -50,12 +41,9 @@ function computeAndRegisterSlots(node: LGraphNode): void {
registerNodeSlots(nodeId, context)
}
/**
* Composable for managing slot layout registration
*/
export function useSlotLayoutSync() {
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
const unsubscribeLayoutChange = ref<() => void>()
const restoreHandlers = ref<() => void>()
/**
* Attempt to start slot layout sync with full event-driven functionality
@@ -77,7 +65,8 @@ export function useSlotLayoutSync() {
}
// Layout changes → recompute slots for changed nodes
unsubscribeLayoutChange = layoutStore.onChange((change) => {
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = layoutStore.onChange((change) => {
for (const nodeId of change.nodeIds) {
const node = graph.getNodeById(parseInt(nodeId))
if (node) {
@@ -131,7 +120,7 @@ export function useSlotLayoutSync() {
}
// Store cleanup function
restoreHandlers = () => {
restoreHandlers.value = () => {
graph.onNodeAdded = origNodeAdded || undefined
graph.onNodeRemoved = origNodeRemoved || undefined
// Only restore onTrigger if Vue nodes are not active
@@ -145,24 +134,14 @@ export function useSlotLayoutSync() {
return true
}
/**
* Stop slot layout sync and cleanup all subscriptions
*/
function stop(): void {
if (unsubscribeLayoutChange) {
unsubscribeLayoutChange()
unsubscribeLayoutChange = null
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = undefined
restoreHandlers.value?.()
restoreHandlers.value = undefined
}
// Auto-cleanup on unmount
onUnmounted(() => {
stop()
})
tryOnScopeDispose(stop)
return {
attemptStart,

View File

@@ -5,7 +5,7 @@
<SlotConnectionDot
ref="connectionDotRef"
:color="slotColor"
class="-translate-x-1/2"
:class="cn('-translate-x-1/2', errorClassesDot)"
v-on="readonly ? {} : { pointerdown: onPointerDown }"
/>
@@ -13,7 +13,9 @@
<div class="relative">
<span
v-if="!dotOnly"
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
:class="
cn('whitespace-nowrap text-sm font-normal lod-toggle', labelClasses)
"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
@@ -39,6 +41,7 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
@@ -57,7 +60,30 @@ interface InputSlotProps {
const props = defineProps<InputSlotProps>()
// Error boundary implementation
const executionStore = useExecutionStore()
const hasSlotError = computed(() => {
const nodeErrors = executionStore.lastNodeErrors?.[props.nodeId ?? '']
if (!nodeErrors) return false
const slotName = props.slotData.name
return nodeErrors.errors.some(
(error) => error.extra_info?.input_name === slotName
)
})
const errorClassesDot = computed(() => {
return hasSlotError.value
? 'ring-2 ring-error dark-theme:ring-error ring-offset-0 rounded-full'
: ''
})
const labelClasses = computed(() =>
hasSlotError.value
? 'text-error dark-theme:text-error font-medium'
: 'dark-theme:text-slate-200 text-stone-200'
)
const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling()
@@ -81,8 +107,12 @@ onErrorCaptured((error) => {
return false
})
// Get slot color based on type
const slotColor = computed(() => getSlotColor(props.slotData.type))
const slotColor = computed(() => {
if (hasSlotError.value) {
return 'var(--color-error)'
}
return getSlotColor(props.slotData.type)
})
const slotWrapperClass = computed(() =>
cn(
@@ -103,8 +133,6 @@ const connectionDotRef = ref<ComponentPublicInstance<{
}> | null>(null)
const slotElRef = ref<HTMLElement | null>(null)
// Watch for when the child component's ref becomes available
// Vue automatically unwraps the Ref when exposing it
watchEffect(() => {
const el = connectionDotRef.value?.slotElRef
slotElRef.value = el || null

View File

@@ -10,7 +10,7 @@
cn(
'bg-white dark-theme:bg-charcoal-800',
'lg-node absolute rounded-2xl',
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
'border-2 border-solid border-sand-100 dark-theme:border-charcoal-600',
// hover (only when node should handle events)
shouldHandleNodePointerEvents &&
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
@@ -34,7 +34,9 @@
:style="[
{
transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
zIndex: zIndex
zIndex: zIndex,
backgroundColor: nodeBodyBackgroundColor,
opacity: nodeOpacity
},
dragStyle
]"
@@ -47,9 +49,14 @@
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
</template>
<!-- Header only updates on title/color changes -->
<NodeHeader
v-memo="[nodeData.title, isCollapsed, nodeData.flags?.pinned]"
v-memo="[
nodeData.title,
nodeData.color,
nodeData.bgcolor,
isCollapsed,
nodeData.flags?.pinned
]"
:node-data="nodeData"
:readonly="readonly"
:collapsed="isCollapsed"
@@ -94,7 +101,11 @@
>
<!-- Slots only rendered at full detail -->
<NodeSlots
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
v-memo="[
nodeData.inputs?.length,
nodeData.outputs?.length,
executionStore.lastNodeErrors
]"
:node-data="nodeData"
:readonly="readonly"
/>
@@ -139,6 +150,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { toggleNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
@@ -148,9 +160,11 @@ import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composable
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
getLocatorIdFromNodeData,
getNodeByLocatorId
@@ -205,22 +219,35 @@ const hasExecutionError = computed(
() => executionStore.lastExecutionErrorNodeId === nodeData.id
)
// Computed error states for styling
const hasAnyError = computed((): boolean => {
return !!(
hasExecutionError.value ||
nodeData.hasErrors ||
error ||
// Type assertions needed because VueNodeData.inputs/outputs are typed as unknown[]
// but at runtime they contain INodeInputSlot/INodeOutputSlot objects
nodeData.inputs?.some((slot) => slot?.hasErrors) ||
nodeData.outputs?.some((slot) => slot?.hasErrors)
(executionStore.lastNodeErrors?.[nodeData.id]?.errors.length ?? 0) > 0
)
})
const bypassed = computed((): boolean => nodeData.mode === 4)
const muted = computed((): boolean => nodeData.mode === 2) // NEVER mode
const nodeBodyBackgroundColor = computed(() => {
const colorPaletteStore = useColorPaletteStore()
if (!nodeData.bgcolor) {
return ''
}
return applyLightThemeColor(
nodeData.bgcolor,
Boolean(colorPaletteStore.completedActivePalette.light_theme)
)
})
const nodeOpacity = computed(
() => useSettingStore().get('Comfy.Node.Opacity') ?? 1
)
// Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
@@ -289,26 +316,19 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
)
const borderClass = computed(() => {
if (hasAnyError.value) {
return 'border-error dark-theme:border-error'
}
if (executing.value) {
return 'border-blue-500'
}
return undefined
return (
(hasAnyError.value && 'border-error dark-theme:border-error') ||
(executing.value && 'border-blue-500')
)
})
const outlineClass = computed(() => {
if (!isSelected.value) {
return undefined
}
if (hasAnyError.value) {
return 'outline-error dark-theme:outline-error'
}
if (executing.value) {
return 'outline-blue-500 dark-theme:outline-blue-500'
}
return 'outline-black dark-theme:outline-white'
return (
isSelected.value &&
((hasAnyError.value && 'outline-error dark-theme:outline-error') ||
(executing.value && 'outline-blue-500 dark-theme:outline-blue-500') ||
'outline-black dark-theme:outline-white')
)
})
// Event handlers

View File

@@ -4,7 +4,8 @@
</div>
<div
v-else
class="lg-node-header p-4 rounded-t-2xl cursor-move"
class="lg-node-header p-4 rounded-t-2xl w-full cursor-move"
:style="headerStyle"
:data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick"
>
@@ -71,8 +72,11 @@ import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
getLocatorIdFromNodeData,
@@ -123,6 +127,23 @@ const tooltipConfig = computed(() => {
return createTooltipConfig(description)
})
const headerStyle = computed(() => {
const colorPaletteStore = useColorPaletteStore()
const opacity = useSettingStore().get('Comfy.Node.Opacity') ?? 1
if (!nodeData?.color) {
return { backgroundColor: '', opacity }
}
const headerColor = applyLightThemeColor(
nodeData.color,
Boolean(colorPaletteStore.completedActivePalette.light_theme)
)
return { backgroundColor: headerColor, opacity }
})
const resolveTitle = (info: VueNodeData | undefined) => {
const title = (info?.title ?? '').trim()
if (title.length > 0) return title

View File

@@ -0,0 +1,14 @@
import { adjustColor } from '@/utils/colorUtil'
/**
* Applies light theme color adjustments to a color
*/
export function applyLightThemeColor(
color: string,
isLightTheme: boolean
): string {
if (!color || !isLightTheme) {
return color
}
return adjustColor(color, { lightness: 0.5 })
}

View File

@@ -157,11 +157,22 @@ export class ComfyApp {
// @ts-expect-error fixme ts strict error
_nodeOutputs: Record<string, any>
nodePreviewImages: Record<string, string[]>
// @ts-expect-error fixme ts strict error
#graph: LGraph
private rootGraphInternal: LGraph | undefined
// TODO: Migrate internal usage to the
/** @deprecated Use {@link rootGraph} instead */
get graph() {
return this.#graph
return this.rootGraphInternal!
}
get rootGraph(): LGraph | undefined {
if (!this.rootGraphInternal) {
console.error('ComfyApp graph accessed before initialization')
}
return this.rootGraphInternal
}
// @ts-expect-error fixme ts strict error
canvas: LGraphCanvas
dragOverNode: LGraphNode | null = null
@@ -765,8 +776,7 @@ export class ComfyApp {
}
}
#addAfterConfigureHandler() {
const { graph } = this
private addAfterConfigureHandler(graph: LGraph) {
const { onConfigure } = graph
graph.onConfigure = function (...args) {
fixLinkInputSlots(this)
@@ -809,10 +819,10 @@ export class ComfyApp {
this.#addConfigureHandler()
this.#addApiUpdateHandlers()
this.#graph = new LGraph()
const graph = new LGraph()
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
this.graph.events.addEventListener('subgraph-created', (e) => {
graph.events.addEventListener('subgraph-created', (e) => {
try {
const { subgraph, data } = e.detail
useSubgraphService().registerNewSubgraph(subgraph, data)
@@ -826,9 +836,10 @@ export class ComfyApp {
}
})
this.#addAfterConfigureHandler()
this.addAfterConfigureHandler(graph)
this.canvas = new LGraphCanvas(canvasEl, this.graph)
this.rootGraphInternal = graph
this.canvas = new LGraphCanvas(canvasEl, graph)
// Make canvas states reactive so we can observe changes on them.
this.canvas.state = reactive(this.canvas.state)

View File

@@ -140,7 +140,6 @@ export function addValueControlWidgets(
valueControl.tooltip =
'Allows the linked widget to be changed automatically, for example randomizing the noise seed.'
// @ts-ignore index with symbol
valueControl[IS_CONTROL_WIDGET] = true
updateControlWidgetLabel(valueControl)
const widgets: [IComboWidget, ...IStringWidget[]] = [valueControl]
@@ -273,12 +272,10 @@ export function addValueControlWidgets(
valueControl.beforeQueued = () => {
if (controlValueRunBefore()) {
// Don't run on first execution
// @ts-ignore index with symbol
if (valueControl[HAS_EXECUTED]) {
applyWidgetControl()
}
}
// @ts-ignore index with symbol
valueControl[HAS_EXECUTED] = true
}