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

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