Cleanup: Litegraph/Vue synchronization work (#5789)

## Summary

Cleanup and fixes to the existing syncing logic.

## Review Focus

This is probably enough to review and test now.

Main things that should still work: 
- moving nodes around
- adding new ones
- switching back and forth between Vue and Litegraph

Let me know if you find any bugs that weren't already present there.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-5789-WIP-Litegraph-Vue-synchronization-work-27a6d73d3650811682cacacb82367b9e)
by [Unito](https://www.unito.io)
This commit is contained in:
Alexander Brown
2025-09-27 16:01:59 -07:00
committed by GitHub
parent 042c2caa88
commit 840f7f04fa
16 changed files with 148 additions and 415 deletions

View File

@@ -100,7 +100,7 @@ test.describe('Vue Node Link Interaction', () => {
const linkDetails = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
const graph = app?.canvas?.graph
if (!graph) return null
const source = graph.getNodeById(sourceId)
@@ -164,7 +164,7 @@ test.describe('Vue Node Link Interaction', () => {
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
const graph = app?.canvas?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)
@@ -207,7 +207,7 @@ test.describe('Vue Node Link Interaction', () => {
const graphLinkCount = await comfyPage.page.evaluate((sourceId) => {
const app = window['app']
const graph = app?.canvas?.graph ?? app?.graph
const graph = app?.canvas?.graph
if (!graph) return 0
const source = graph.getNodeById(sourceId)

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'
@@ -189,8 +190,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 +226,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) {
@@ -364,7 +364,6 @@ const loadCustomNodesI18n = async () => {
const comfyAppReady = ref(false)
const workflowPersistence = useWorkflowPersistence()
// @ts-expect-error fixme ts strict error
useCanvasDrop(canvasRef)
useLitegraphSettings()
useNodeBadge()

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

@@ -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

@@ -10,7 +10,6 @@ import type {
SlotDragSource,
SlotDropCandidate
} from '@/renderer/core/canvas/links/slotLinkDragState'
import { app } from '@/scripts/app'
interface CompatibilityResult {
allowable: boolean
@@ -21,7 +20,7 @@ interface CompatibilityResult {
function resolveNode(nodeId: NodeId) {
const pinia = getActivePinia()
const canvasStore = pinia ? useCanvasStore() : null
const graph = canvasStore?.canvas?.graph ?? app.canvas?.graph
const graph = canvasStore?.canvas?.graph
if (!graph) return null
const id = typeof nodeId === 'string' ? Number(nodeId) : nodeId
if (Number.isNaN(id)) return null

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

@@ -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
}