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 { import {
comfyExpect as expect, comfyExpect as expect,
comfyPageFixture as test comfyPageFixture as test
} from '../../../fixtures/ComfyPage' } from '../../../../fixtures/ComfyPage'
test.describe('Vue Nodes Zoom', () => { test.describe('Vue Nodes Zoom', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {

View File

@@ -1,13 +1,13 @@
import type { Locator, Page } from '@playwright/test' import type { Locator, Page } from '@playwright/test'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema' import type { NodeId } from '../../../../../src/platform/workflow/validation/schemas/workflowSchema'
import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier' import { getSlotKey } from '../../../../../src/renderer/core/layout/slots/slotIdentifier'
import { import {
comfyExpect as expect, comfyExpect as expect,
comfyPageFixture as test comfyPageFixture as test
} from '../../fixtures/ComfyPage' } from '../../../../fixtures/ComfyPage'
import { getMiddlePoint } from '../../fixtures/utils/litegraphUtils' import { getMiddlePoint } from '../../../../fixtures/utils/litegraphUtils'
import { fitToViewInstant } from '../../helpers/fitToView' import { fitToViewInstant } from '../../../../helpers/fitToView'
async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { async function getCenter(locator: Locator): Promise<{ x: number; y: number }> {
const box = await locator.boundingBox() const box = await locator.boundingBox()
@@ -189,6 +189,13 @@ test.describe('Vue Node Link Interaction', () => {
expect(await samplerOutput.getLinkCount()).toBe(0) expect(await samplerOutput.getLinkCount()).toBe(0)
expect(await clipInput.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 ({ 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 samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0] const vaeNode = (await comfyPage.getNodeRefsByType('VAEDecode'))[0]
expect(samplerNode && vaeNode).toBeTruthy() expect(samplerNode && vaeNode).toBeTruthy()
const samplerOutputCenter = await getSlotCenter( const samplerOutputCenter = await getSlotCenter(
comfyPage.page, comfyPage.page,
samplerNode.id, samplerNode.id,

View File

@@ -1,6 +1,6 @@
import { expect } from '@playwright/test' import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage' import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') 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 { import {
comfyExpect as expect, comfyExpect as expect,
comfyPageFixture as test comfyPageFixture as test
} from '../../../fixtures/ComfyPage' } from '../../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,7 +15,8 @@ test.describe('Vue Node Selection', () => {
const modifiers = [ const modifiers = [
{ key: 'Control', name: 'ctrl' }, { key: 'Control', name: 'ctrl' },
{ key: 'Shift', name: 'shift' } { key: 'Shift', name: 'shift' },
{ key: 'Meta', name: 'meta' }
] as const ] as const
for (const { key: modifier, name } of modifiers) { 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 { import {
comfyExpect as expect, comfyExpect as expect,
comfyPageFixture as test comfyPageFixture as test
} from '../../fixtures/ComfyPage' } from '../../../fixtures/ComfyPage'
import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' import { VueNodeFixture } from '../../../fixtures/utils/vueNodeFixtures'
test.describe('NodeHeader', () => { test.describe('Vue Node Collapse', () => {
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false)
await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.EnableTooltips', true)
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup() await comfyPage.setup()
}) })
test('displays node title', async ({ comfyPage }) => { test('should allow collapsing node with collapse icon', async ({
// 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 ({
comfyPage 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 // Get the KSampler node from the default workflow
const nodes = await comfyPage.getNodeRefsByType('KSampler') const nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0] const node = nodes[0]
@@ -103,7 +43,7 @@ test.describe('NodeHeader', () => {
expect(expandedSize.height).toBeGreaterThanOrEqual(collapsedSize.height) 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 nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0] const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page) const vueNode = new VueNodeFixture(node, comfyPage.page)
@@ -123,7 +63,9 @@ test.describe('NodeHeader', () => {
expect(iconClass).toContain('pi-chevron-down') 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 nodes = await comfyPage.getNodeRefsByType('KSampler')
const node = nodes[0] const node = nodes[0]
const vueNode = new VueNodeFixture(node, comfyPage.page) 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 { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage' import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => { test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled') 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", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.28.2", "version": "1.28.3",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",
@@ -158,4 +158,4 @@
"zod": "^3.23.8", "zod": "^3.23.8",
"zod-validation-error": "^3.3.0" "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 SideToolbar from '@/components/sidebar/SideToolbar.vue'
import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue' import SecondRowWorkflowTabs from '@/components/topbar/SecondRowWorkflowTabs.vue'
import { useChainCallback } from '@/composables/functional/useChainCallback' import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useViewportCulling } from '@/composables/graph/useViewportCulling' import { useViewportCulling } from '@/composables/graph/useViewportCulling'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useNodeBadge } from '@/composables/node/useNodeBadge' import { useNodeBadge } from '@/composables/node/useNodeBadge'
@@ -103,7 +104,6 @@ import { useGlobalLitegraph } from '@/composables/useGlobalLitegraph'
import { usePaste } from '@/composables/usePaste' import { usePaste } from '@/composables/usePaste'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { i18n, t } from '@/i18n' import { i18n, t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings' import { useLitegraphSettings } from '@/platform/settings/composables/useLitegraphSettings'
import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings' import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings'
import { useSettingStore } from '@/platform/settings/settingStore' import { useSettingStore } from '@/platform/settings/settingStore'
@@ -189,8 +189,8 @@ watch(
} }
) )
const allNodes = computed(() => const allNodes = computed((): VueNodeData[] =>
Array.from(vueNodeLifecycle.vueNodeData.value.values()) Array.from(vueNodeLifecycle.nodeManager.value?.vueNodeData?.values() ?? [])
) )
watchEffect(() => { watchEffect(() => {
@@ -225,7 +225,6 @@ watch(
for (const n of comfyApp.graph.nodes) { for (const n of comfyApp.graph.nodes) {
if (!n.widgets) continue if (!n.widgets) continue
for (const w of n.widgets) { for (const w of n.widgets) {
// @ts-expect-error fixme ts strict error
if (w[IS_CONTROL_WIDGET]) { if (w[IS_CONTROL_WIDGET]) {
updateControlWidgetLabel(w) updateControlWidgetLabel(w)
if (w.linkedWidgets) { if (w.linkedWidgets) {
@@ -293,45 +292,36 @@ watch(
{ deep: true } { deep: true }
) )
// Update node slot errors // Update node slot errors for LiteGraph nodes
// (Vue nodes read from store directly)
watch( watch(
() => executionStore.lastNodeErrors, () => executionStore.lastNodeErrors,
(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) { for (const slot of node.inputs) {
delete slot.hasErrors delete slot.hasErrors
} }
for (const slot of node.outputs) { for (const slot of node.outputs) {
delete slot.hasErrors delete slot.hasErrors
} }
}
for (const node of comfyApp.graph.nodes) {
removeSlotError(node)
const nodeErrors = lastNodeErrors?.[node.id] const nodeErrors = lastNodeErrors?.[node.id]
if (!nodeErrors) continue if (!nodeErrors) continue
const validErrors = nodeErrors.errors.filter( const validErrors = nodeErrors.errors.filter(
(error) => error.extra_info?.input_name !== undefined (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 validErrors.forEach((error) => {
if (slotErrorsChanged && comfyApp.graph.onTrigger) { const inputName = error.extra_info!.input_name!
comfyApp.graph.onTrigger('node:slot-errors:changed', { const inputIndex = node.findInputSlot(inputName)
nodeId: node.id if (inputIndex !== -1) {
}) node.inputs[inputIndex].hasErrors = true
} }
})
} }
comfyApp.canvas.draw(true, true) comfyApp.canvas.draw(true, true)
@@ -364,7 +354,6 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false) const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence() const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef) useCanvasDrop(canvasRef)
useLitegraphSettings() useLitegraphSettings()
useNodeBadge() useNodeBadge()

View File

@@ -41,6 +41,8 @@ export interface VueNodeData {
collapsed?: boolean collapsed?: boolean
pinned?: boolean pinned?: boolean
} }
color?: string
bgcolor?: string
} }
export interface GraphNodeManager { export interface GraphNodeManager {
@@ -126,7 +128,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
widgets: safeWidgets, widgets: safeWidgets,
inputs: node.inputs ? [...node.inputs] : undefined, inputs: node.inputs ? [...node.inputs] : undefined,
outputs: node.outputs ? [...node.outputs] : 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, ...currentData,
mode: typeof event.newValue === 'number' ? event.newValue : 0 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 ( } else if (

View File

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

View File

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

View File

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

View File

@@ -170,7 +170,7 @@ class GroupNodeBuilder {
// Use the built in copyToClipboard function to generate the node data we need // Use the built in copyToClipboard function to generate the node data we need
try { try {
// @ts-expect-error fixme ts strict error // @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) const config = JSON.parse(serialised)
storeLinkTypes(config) storeLinkTypes(config)

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@
* rendering data that can be consumed by the PathRenderer. * rendering data that can be consumed by the PathRenderer.
* Maintains backward compatibility with existing litegraph integration. * 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 { LLink } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute' import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { import type {
@@ -19,7 +18,6 @@ import {
LinkMarkerShape, LinkMarkerShape,
LinkRenderType LinkRenderType
} from '@/lib/litegraph/src/types/globalEnums' } from '@/lib/litegraph/src/types/globalEnums'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { import {
type ArrowShape, type ArrowShape,
CanvasPathRenderer, CanvasPathRenderer,
@@ -54,142 +52,10 @@ export interface LinkRenderContext {
disabledPattern?: CanvasPattern | null disabledPattern?: CanvasPattern | null
} }
interface LinkRenderOptions {
color?: CanvasColour
flow?: boolean
skipBorder?: boolean
disabled?: boolean
}
export class LitegraphLinkAdapter { export class LitegraphLinkAdapter {
private graph: LGraph private readonly pathRenderer = new CanvasPathRenderer()
private pathRenderer: CanvasPathRenderer
public enableLayoutStoreWrites = true
constructor(graph: LGraph) { constructor(public readonly enableLayoutStoreWrites = true) {}
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
}
}
/** /**
* Convert LinkDirection enum to Direction string * Convert LinkDirection enum to Direction string

View File

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

View File

@@ -1,14 +1,6 @@
/** import { tryOnScopeDispose } from '@vueuse/core'
* Composable for event-driven link layout synchronization import { computed, ref, toValue } from 'vue'
*
* 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 type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas' import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink' import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute' 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 { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { LayoutChange } from '@/renderer/core/layout/types' import type { LayoutChange } from '@/renderer/core/layout/types'
const logger = log.getLogger('useLinkLayoutSync')
/**
* Composable for managing link layout synchronization
*/
export function useLinkLayoutSync() { export function useLinkLayoutSync() {
let canvas: LGraphCanvas | null = null const canvasRef = ref<LGraphCanvas>()
let graph: LGraph | null = null const graphRef = computed(() => canvasRef.value?.graph)
let offscreenCtx: CanvasRenderingContext2D | null = null const unsubscribeLayoutChange = ref<() => void>()
let adapter: LitegraphLinkAdapter | null = null const adapter = new LitegraphLinkAdapter()
let unsubscribeLayoutChange: (() => void) | null = null
let restoreHandlers: (() => void) | null = null
/** /**
* Build link render context from canvas properties * Build link render context from canvas properties
*/ */
function buildLinkRenderContext(): LinkRenderContext { function buildLinkRenderContext(): LinkRenderContext {
const canvas = toValue(canvasRef)
if (!canvas) { if (!canvas) {
throw new Error('Canvas not initialized') throw new Error('Canvas not initialized')
} }
@@ -73,7 +59,9 @@ export function useLinkLayoutSync() {
* - No dragging state handling (pure geometry computation) * - No dragging state handling (pure geometry computation)
*/ */
function recomputeLinkById(linkId: number): void { 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) const link = graph.links.get(linkId)
if (!link || link.id === -1) return // Skip floating/temp links if (!link || link.id === -1) return // Skip floating/temp links
@@ -131,7 +119,7 @@ export function useLinkLayoutSync() {
// Render segment to this reroute // Render segment to this reroute
adapter.renderLinkDirect( adapter.renderLinkDirect(
offscreenCtx, canvas.ctx,
segmentStartPos, segmentStartPos,
reroute.pos, reroute.pos,
link, link,
@@ -167,7 +155,7 @@ export function useLinkLayoutSync() {
] ]
adapter.renderLinkDirect( adapter.renderLinkDirect(
offscreenCtx, canvas.ctx,
lastReroute.pos, lastReroute.pos,
endPos, endPos,
link, link,
@@ -185,7 +173,7 @@ export function useLinkLayoutSync() {
} else { } else {
// No reroutes - render direct link // No reroutes - render direct link
adapter.renderLinkDirect( adapter.renderLinkDirect(
offscreenCtx, canvas.ctx,
startPos, startPos,
endPos, endPos,
link, link,
@@ -206,6 +194,7 @@ export function useLinkLayoutSync() {
* Recompute all links connected to a node * Recompute all links connected to a node
*/ */
function recomputeLinksForNode(nodeId: number): void { function recomputeLinksForNode(nodeId: number): void {
const graph = toValue(graphRef)
if (!graph) return if (!graph) return
const node = graph.getNodeById(nodeId) const node = graph.getNodeById(nodeId)
@@ -243,6 +232,7 @@ export function useLinkLayoutSync() {
* Recompute all links associated with a reroute * Recompute all links associated with a reroute
*/ */
function recomputeLinksForReroute(rerouteId: number): void { function recomputeLinksForReroute(rerouteId: number): void {
const graph = toValue(graphRef)
if (!graph) return if (!graph) return
const reroute = graph.reroutes.get(rerouteId) const reroute = graph.reroutes.get(rerouteId)
@@ -258,105 +248,55 @@ export function useLinkLayoutSync() {
* Start link layout sync with event-driven functionality * Start link layout sync with event-driven functionality
*/ */
function start(canvasInstance: LGraphCanvas): void { function start(canvasInstance: LGraphCanvas): void {
canvas = canvasInstance canvasRef.value = canvasInstance
graph = canvas.graph if (!canvasInstance.graph) return
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
// Initial computation for all existing links // 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) { if (link.id !== -1) {
recomputeLinkById(link.id) recomputeLinkById(link.id)
} }
} }
// Subscribe to layout store changes // Subscribe to layout store changes
unsubscribeLayoutChange = layoutStore.onChange((change: LayoutChange) => { unsubscribeLayoutChange.value?.()
switch (change.operation.type) { unsubscribeLayoutChange.value = layoutStore.onChange(
case 'moveNode': (change: LayoutChange) => {
case 'resizeNode': switch (change.operation.type) {
recomputeLinksForNode(parseInt(change.operation.nodeId)) case 'moveNode':
break case 'resizeNode':
case 'createLink': recomputeLinksForNode(parseInt(change.operation.nodeId))
recomputeLinkById(change.operation.linkId) break
break case 'createLink':
case 'deleteLink': recomputeLinkById(change.operation.linkId)
// No-op - store already cleaned by existing code break
break case 'deleteLink':
case 'createReroute': // No-op - store already cleaned by existing code
case 'deleteReroute': break
// Recompute all affected links case 'createReroute':
if ('linkIds' in change.operation) { case 'deleteReroute':
for (const linkId of change.operation.linkIds) { // Recompute all affected links
recomputeLinkById(linkId) if ('linkIds' in change.operation) {
for (const linkId of change.operation.linkIds) {
recomputeLinkById(linkId)
}
} }
} break
break case 'moveReroute':
case 'moveReroute': recomputeLinksForReroute(change.operation.rerouteId)
recomputeLinksForReroute(change.operation.rerouteId) break
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)
} }
} }
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 { function stop(): void {
if (unsubscribeLayoutChange) { unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange() unsubscribeLayoutChange.value = undefined
unsubscribeLayoutChange = null canvasRef.value = undefined
}
if (restoreHandlers) {
restoreHandlers()
restoreHandlers = null
}
canvas = null
graph = null
offscreenCtx = null
adapter = null
} }
// Auto-cleanup on unmount tryOnScopeDispose(stop)
onUnmounted(() => {
stop()
})
return { return {
start, start,

View File

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

View File

@@ -5,7 +5,7 @@
<SlotConnectionDot <SlotConnectionDot
ref="connectionDotRef" ref="connectionDotRef"
:color="slotColor" :color="slotColor"
class="-translate-x-1/2" :class="cn('-translate-x-1/2', errorClassesDot)"
v-on="readonly ? {} : { pointerdown: onPointerDown }" v-on="readonly ? {} : { pointerdown: onPointerDown }"
/> />
@@ -13,7 +13,9 @@
<div class="relative"> <div class="relative">
<span <span
v-if="!dotOnly" 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}` }} {{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span> </span>
@@ -39,6 +41,7 @@ import type { INodeSlot } from '@/lib/litegraph/src/litegraph'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking' import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue' import LODFallback from './LODFallback.vue'
@@ -57,7 +60,30 @@ interface InputSlotProps {
const props = defineProps<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 renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling() const { toastErrorHandler } = useErrorHandling()
@@ -81,8 +107,12 @@ onErrorCaptured((error) => {
return false return false
}) })
// Get slot color based on type const slotColor = computed(() => {
const slotColor = computed(() => getSlotColor(props.slotData.type)) if (hasSlotError.value) {
return 'var(--color-error)'
}
return getSlotColor(props.slotData.type)
})
const slotWrapperClass = computed(() => const slotWrapperClass = computed(() =>
cn( cn(
@@ -103,8 +133,6 @@ const connectionDotRef = ref<ComponentPublicInstance<{
}> | null>(null) }> | null>(null)
const slotElRef = ref<HTMLElement | 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(() => { watchEffect(() => {
const el = connectionDotRef.value?.slotElRef const el = connectionDotRef.value?.slotElRef
slotElRef.value = el || null slotElRef.value = el || null

View File

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

View File

@@ -4,7 +4,8 @@
</div> </div>
<div <div
v-else 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 || ''}`" :data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
> >
@@ -71,8 +72,11 @@ import EditableText from '@/components/common/EditableText.vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n' import { st } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { applyLightThemeColor } from '@/renderer/extensions/vueNodes/utils/nodeStyleUtils'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { import {
getLocatorIdFromNodeData, getLocatorIdFromNodeData,
@@ -123,6 +127,23 @@ const tooltipConfig = computed(() => {
return createTooltipConfig(description) 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 resolveTitle = (info: VueNodeData | undefined) => {
const title = (info?.title ?? '').trim() const title = (info?.title ?? '').trim()
if (title.length > 0) return title 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 // @ts-expect-error fixme ts strict error
_nodeOutputs: Record<string, any> _nodeOutputs: Record<string, any>
nodePreviewImages: Record<string, string[]> 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() { 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 // @ts-expect-error fixme ts strict error
canvas: LGraphCanvas canvas: LGraphCanvas
dragOverNode: LGraphNode | null = null dragOverNode: LGraphNode | null = null
@@ -765,8 +776,7 @@ export class ComfyApp {
} }
} }
#addAfterConfigureHandler() { private addAfterConfigureHandler(graph: LGraph) {
const { graph } = this
const { onConfigure } = graph const { onConfigure } = graph
graph.onConfigure = function (...args) { graph.onConfigure = function (...args) {
fixLinkInputSlots(this) fixLinkInputSlots(this)
@@ -809,10 +819,10 @@ export class ComfyApp {
this.#addConfigureHandler() this.#addConfigureHandler()
this.#addApiUpdateHandlers() this.#addApiUpdateHandlers()
this.#graph = new LGraph() const graph = new LGraph()
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory // 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 { try {
const { subgraph, data } = e.detail const { subgraph, data } = e.detail
useSubgraphService().registerNewSubgraph(subgraph, data) 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. // Make canvas states reactive so we can observe changes on them.
this.canvas.state = reactive(this.canvas.state) this.canvas.state = reactive(this.canvas.state)

View File

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