Fix: Vue-Litegraph conversion bug with Reroutes (#6330)

## Summary

The private fields triggered an error in intitializing the
linkLayoutSync. Turns out that wasn't necessary anymore.

> [!NOTE]
> Edit: Doing some more investigation, it looks like the slot sync can
also be removed?

## Changes

- **What**: Converts JS private fields to typescript private, adds some
readonly declarations
- **What**: Removes the useLinkLayoutSync usage in useVueNodeLifecycle
- **What**: Removes the useSlotLayoutSync usage in useVueNodeLifecycle

## Review Focus

Was the sync doing something that wouldn't be caught in normal
usage/testing?

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6330-Fix-Vue-Litegraph-conversion-bug-with-Reroutes-2996d73d3650819ebb24e4aa2fc51c65)
by [Unito](https://www.unito.io)
This commit is contained in:
Alexander Brown
2025-10-27 19:36:19 -07:00
committed by GitHub
parent d1c9ce5a66
commit 6afdb9529d
9 changed files with 82 additions and 637 deletions

1
.gitattributes vendored
View File

@@ -7,6 +7,7 @@
*.json text eol=lf
*.mjs text eol=lf
*.mts text eol=lf
*.snap text eol=lf
*.ts text eol=lf
*.vue text eol=lf
*.yaml text eol=lf

View File

@@ -14,7 +14,7 @@ const config: KnipConfig = {
},
'apps/desktop-ui': {
entry: ['src/main.ts', 'src/i18n.ts'],
project: ['src/**/*.{js,ts,vue}', '*.{js,ts,mts}']
project: ['src/**/*.{js,ts,vue}']
},
'packages/tailwind-utils': {
project: ['src/**/*.{js,ts}']

View File

@@ -4,13 +4,11 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } 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 type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync'
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
import { app as comfyApp } from '@/scripts/app'
function useVueNodeLifecycleIndividual() {
@@ -21,8 +19,6 @@ function useVueNodeLifecycleIndividual() {
const nodeManager = shallowRef<GraphNodeManager | null>(null)
const { startSync } = useLayoutSync()
const linkSyncManager = useLinkLayoutSync()
const slotSyncManager = useSlotLayoutSync()
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
@@ -62,10 +58,6 @@ function useVueNodeLifecycleIndividual() {
// Initialize layout sync (one-way: Layout Store → LiteGraph)
startSync(canvasStore.canvas)
if (comfyApp.canvas) {
linkSyncManager.start(comfyApp.canvas)
}
}
const disposeNodeManagerAndSyncs = () => {
@@ -77,8 +69,6 @@ function useVueNodeLifecycleIndividual() {
/* empty */
}
nodeManager.value = null
linkSyncManager.stop()
}
// Watch for Vue nodes enabled state changes
@@ -96,25 +86,14 @@ function useVueNodeLifecycleIndividual() {
// Consolidated watch for slot layout sync management
watch(
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
([canvas, vueMode], [, oldVueMode]) => {
() => shouldRenderVueNodes.value,
(vueMode, oldVueMode) => {
const modeChanged = vueMode !== oldVueMode
// Clear stale slot layouts when switching modes
if (modeChanged) {
layoutStore.clearAllSlotLayouts()
}
// Switching to Vue
if (vueMode) {
slotSyncManager.stop()
}
// Switching to LG
const shouldRun = Boolean(canvas?.graph) && !vueMode
if (shouldRun && canvas) {
slotSyncManager.attemptStart(canvas as LGraphCanvas)
}
},
{ immediate: true, flush: 'sync' }
)
@@ -152,8 +131,6 @@ function useVueNodeLifecycleIndividual() {
nodeManager.value.cleanup()
nodeManager.value = null
}
slotSyncManager.stop()
linkSyncManager.stop()
}
return {

View File

@@ -223,15 +223,15 @@ export class LGraph
/** Internal only. Not required for serialisation; calculated on deserialise. */
#lastFloatingLinkId: number = 0
#floatingLinks: Map<LinkId, LLink> = new Map()
private readonly floatingLinksInternal: Map<LinkId, LLink> = new Map()
get floatingLinks(): ReadonlyMap<LinkId, LLink> {
return this.#floatingLinks
return this.floatingLinksInternal
}
#reroutes = new Map<RerouteId, Reroute>()
private readonly reroutesInternal = new Map<RerouteId, Reroute>()
/** All reroutes in this graph. */
public get reroutes(): Map<RerouteId, Reroute> {
return this.#reroutes
return this.reroutesInternal
}
get rootGraph(): LGraph {
@@ -340,7 +340,7 @@ export class LGraph
this._links.clear()
this.reroutes.clear()
this.#floatingLinks.clear()
this.floatingLinksInternal.clear()
this.#lastFloatingLinkId = 0
@@ -1268,7 +1268,7 @@ export class LGraph
if (link.id === -1) {
link.id = ++this.#lastFloatingLinkId
}
this.#floatingLinks.set(link.id, link)
this.floatingLinksInternal.set(link.id, link)
const slot =
link.target_id !== -1
@@ -1291,7 +1291,7 @@ export class LGraph
}
removeFloatingLink(link: LLink): void {
this.#floatingLinks.delete(link.id)
this.floatingLinksInternal.delete(link.id)
const slot =
link.target_id !== -1

View File

@@ -51,31 +51,31 @@ export class Reroute
}
/** The network this reroute belongs to. Contains all valid links and reroutes. */
#network: WeakRef<LinkNetwork>
private readonly network: WeakRef<LinkNetwork>
#parentId?: RerouteId
private parentIdInternal?: RerouteId
public get parentId(): RerouteId | undefined {
return this.#parentId
return this.parentIdInternal
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value) {
if (value === this.id) return
if (this.getReroutes() === null) return
this.#parentId = value
this.parentIdInternal = value
}
public get parent(): Reroute | undefined {
return this.#network.deref()?.getReroute(this.#parentId)
return this.network.deref()?.getReroute(this.parentIdInternal)
}
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot
#pos: Point = [0, 0]
private readonly posInternal: Point = [0, 0]
/** @inheritdoc */
get pos(): Point {
return this.#pos
return this.posInternal
}
set pos(value: Point) {
@@ -83,14 +83,14 @@ export class Reroute
throw new TypeError(
'Reroute.pos is an x,y point, and expects an indexable with at least two values.'
)
this.#pos[0] = value[0]
this.#pos[1] = value[1]
this.posInternal[0] = value[0]
this.posInternal[1] = value[1]
}
/** @inheritdoc */
get boundingRect(): ReadOnlyRect {
const { radius } = Reroute
const [x, y] = this.#pos
const [x, y] = this.posInternal
return [x - radius, y - radius, 2 * radius, 2 * radius]
}
@@ -98,11 +98,11 @@ export class Reroute
* Slightly over-sized rectangle, guaranteed to contain the entire surface area for hover detection.
* Eliminates most hover positions using an extremely cheap check.
*/
get #hoverArea(): ReadOnlyRect {
private get hoverArea(): ReadOnlyRect {
const xOffset = 2 * Reroute.slotOffset
const yOffset = 2 * Math.max(Reroute.radius, Reroute.slotRadius)
const [x, y] = this.#pos
const [x, y] = this.posInternal
return [x - xOffset, y - yOffset, 2 * xOffset, 2 * yOffset]
}
@@ -149,35 +149,35 @@ export class Reroute
* Used to ensure reroute angles are only executed once per frame.
* @todo Calculate on change instead.
*/
#lastRenderTime: number = -Infinity
private lastRenderTime: number = -Infinity
#inputSlot = new RerouteSlot(this, true)
#outputSlot = new RerouteSlot(this, false)
private readonly inputSlot = new RerouteSlot(this, true)
private readonly outputSlot = new RerouteSlot(this, false)
get isSlotHovered(): boolean {
return this.isInputHovered || this.isOutputHovered
}
get isInputHovered(): boolean {
return this.#inputSlot.hovering
return this.inputSlot.hovering
}
get isOutputHovered(): boolean {
return this.#outputSlot.hovering
return this.outputSlot.hovering
}
get firstLink(): LLink | undefined {
const linkId = this.linkIds.values().next().value
return linkId === undefined
? undefined
: this.#network.deref()?.links.get(linkId)
: this.network.deref()?.links.get(linkId)
}
get firstFloatingLink(): LLink | undefined {
const linkId = this.floatingLinkIds.values().next().value
return linkId === undefined
? undefined
: this.#network.deref()?.floatingLinks.get(linkId)
: this.network.deref()?.floatingLinks.get(linkId)
}
/** @inheritdoc */
@@ -205,7 +205,7 @@ export class Reroute
linkIds?: Iterable<LinkId>,
floatingLinkIds?: Iterable<LinkId>
) {
this.#network = new WeakRef(network)
this.network = new WeakRef(network)
this.parentId = parentId
if (pos) this.pos = pos
this.linkIds = new Set(linkIds)
@@ -261,15 +261,15 @@ export class Reroute
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null {
// No parentId - last in the chain
if (this.#parentId === undefined) return [this]
if (this.parentIdInternal === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.#network.deref()?.reroutes.get(this.#parentId)
const parent = this.network.deref()?.reroutes.get(this.parentIdInternal)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.#parentId = undefined
this.parentIdInternal = undefined
return [this]
}
@@ -288,14 +288,14 @@ export class Reroute
withParentId: RerouteId,
visited = new Set<Reroute>()
): Reroute | null | undefined {
if (this.#parentId === withParentId) return this
if (this.parentIdInternal === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
if (this.#parentId === undefined) return
if (this.parentIdInternal === undefined) return
return this.#network
return this.network
.deref()
?.reroutes.get(this.#parentId)
?.reroutes.get(this.parentIdInternal)
?.findNextReroute(withParentId, visited)
}
@@ -309,7 +309,7 @@ export class Reroute
const link = this.firstLink ?? this.firstFloatingLink
if (!link) return
const node = this.#network.deref()?.getNodeById(link.origin_id)
const node = this.network.deref()?.getNodeById(link.origin_id)
if (!node) return
return {
@@ -325,7 +325,7 @@ export class Reroute
findTargetInputs():
| { node: LGraphNode; input: INodeInputSlot; link: LLink }[]
| undefined {
const network = this.#network.deref()
const network = this.network.deref()
if (!network) return
const results: {
@@ -363,7 +363,7 @@ export class Reroute
* @returns An array of floating links
*/
getFloatingLinks(from: 'input' | 'output'): LLink[] | undefined {
const floatingLinks = this.#network.deref()?.floatingLinks
const floatingLinks = this.network.deref()?.floatingLinks
if (!floatingLinks) return
const idProp = from === 'input' ? 'origin_id' : 'target_id'
@@ -387,7 +387,7 @@ export class Reroute
output: INodeOutputSlot,
index: number
) {
const network = this.#network.deref()
const network = this.network.deref()
const floatingOutLinks = this.getFloatingLinks('output')
if (!floatingOutLinks)
throw new Error('[setFloatingLinkOrigin]: Invalid network.')
@@ -411,15 +411,15 @@ export class Reroute
/** @inheritdoc */
move(deltaX: number, deltaY: number) {
const previousPos = { x: this.#pos[0], y: this.#pos[1] }
this.#pos[0] += deltaX
this.#pos[1] += deltaY
const previousPos = { x: this.posInternal[0], y: this.posInternal[1] }
this.posInternal[0] += deltaX
this.posInternal[1] += deltaY
// Update Layout Store with new position
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(
this.id,
{ x: this.#pos[0], y: this.#pos[1] },
{ x: this.posInternal[0], y: this.posInternal[1] },
previousPos
)
}
@@ -441,7 +441,7 @@ export class Reroute
}
removeFloatingLink(linkId: LinkId) {
const network = this.#network.deref()
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(linkId)
@@ -462,7 +462,7 @@ export class Reroute
* @remarks Does not remove the link from the network.
*/
removeLink(link: LLink) {
const network = this.#network.deref()
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(link.id)
@@ -474,7 +474,7 @@ export class Reroute
}
remove() {
const network = this.#network.deref()
const network = this.network.deref()
if (!network) return
network.removeReroute(this.id)
@@ -486,8 +486,8 @@ export class Reroute
linkStart: Point
): void {
// Ensure we run once per render
if (!(lastRenderTime > this.#lastRenderTime)) return
this.#lastRenderTime = lastRenderTime
if (!(lastRenderTime > this.lastRenderTime)) return
this.lastRenderTime = lastRenderTime
const { id, pos: thisPos } = this
@@ -509,14 +509,14 @@ export class Reroute
sum /= angles.length
const originToReroute = Math.atan2(
this.#pos[1] - linkStart[1],
this.#pos[0] - linkStart[0]
this.posInternal[1] - linkStart[1],
this.posInternal[0] - linkStart[0]
)
let diff = (originToReroute - sum) * 0.5
if (Math.abs(diff) > Math.PI * 0.5) diff += Math.PI
const dist = Math.min(
Reroute.maxSplineOffset,
distance(linkStart, this.#pos) * 0.25
distance(linkStart, this.posInternal) * 0.25
)
// Store results
@@ -604,8 +604,8 @@ export class Reroute
* @param ctx The canvas context to draw on.
*/
drawSlots(ctx: CanvasRenderingContext2D): void {
this.#inputSlot.draw(ctx)
this.#outputSlot.draw(ctx)
this.inputSlot.draw(ctx)
this.outputSlot.draw(ctx)
}
drawHighlight(ctx: CanvasRenderingContext2D, colour: CanvasColour): void {
@@ -629,8 +629,8 @@ export class Reroute
* @returns `true` if any changes require a redraw.
*/
updateVisibility(pos: Point): boolean {
const input = this.#inputSlot
const output = this.#outputSlot
const input = this.inputSlot
const output = this.outputSlot
input.dirty = false
output.dirty = false
@@ -642,8 +642,8 @@ export class Reroute
const showEither = showInput || showOutput
// Check if even in the vicinity
if (showEither && isPointInRect(pos, this.#hoverArea)) {
const outlineOnly = this.#contains(pos)
if (showEither && isPointInRect(pos, this.hoverArea)) {
const outlineOnly = this.contains(pos)
if (showInput) input.update(pos, outlineOnly)
if (showOutput) output.update(pos, outlineOnly)
@@ -656,8 +656,8 @@ export class Reroute
/** Prevents rendering of the input and output slots. */
hideSlots() {
this.#inputSlot.hide()
this.#outputSlot.hide()
this.inputSlot.hide()
this.outputSlot.hide()
}
/**
@@ -666,10 +666,10 @@ export class Reroute
* @returns `true` if {@link pos} is within the reroute's radius.
*/
containsPoint(pos: Point): boolean {
return isPointInRect(pos, this.#hoverArea) && this.#contains(pos)
return isPointInRect(pos, this.hoverArea) && this.contains(pos)
}
#contains(pos: Point): boolean {
private contains(pos: Point): boolean {
return distance(this.pos, pos) <= Reroute.radius
}
@@ -692,47 +692,47 @@ export class Reroute
*/
class RerouteSlot {
/** The reroute that the slot belongs to. */
readonly #reroute: Reroute
private readonly reroute: Reroute
readonly #offsetMultiplier: 1 | -1
private readonly offsetMultiplier: 1 | -1
/** Centre point of this slot. */
get pos(): Point {
const [x, y] = this.#reroute.pos
return [x + Reroute.slotOffset * this.#offsetMultiplier, y]
const [x, y] = this.reroute.pos
return [x + Reroute.slotOffset * this.offsetMultiplier, y]
}
/** Whether any changes require a redraw. */
dirty: boolean = false
#hovering = false
private hoveringInternal = false
/** Whether the pointer is hovering over the slot itself. */
get hovering() {
return this.#hovering
return this.hoveringInternal
}
set hovering(value) {
if (!Object.is(this.#hovering, value)) {
this.#hovering = value
if (!Object.is(this.hoveringInternal, value)) {
this.hoveringInternal = value
this.dirty = true
}
}
#showOutline = false
private showOutlineInternal = false
/** Whether the slot outline / faint background is visible. */
get showOutline() {
return this.#showOutline
return this.showOutlineInternal
}
set showOutline(value) {
if (!Object.is(this.#showOutline, value)) {
this.#showOutline = value
if (!Object.is(this.showOutlineInternal, value)) {
this.showOutlineInternal = value
this.dirty = true
}
}
constructor(reroute: Reroute, isInput: boolean) {
this.#reroute = reroute
this.#offsetMultiplier = isInput ? -1 : 1
this.reroute = reroute
this.offsetMultiplier = isInput ? -1 : 1
}
/**
@@ -771,7 +771,7 @@ class RerouteSlot {
if (!showOutline) return
try {
ctx.fillStyle = hovering ? this.#reroute.colour : 'rgba(127,127,127,0.3)'
ctx.fillStyle = hovering ? this.reroute.colour : 'rgba(127,127,127,0.3)'
ctx.strokeStyle = 'rgb(0,0,0,0.5)'
ctx.lineWidth = 1

View File

@@ -1,75 +0,0 @@
/**
* Slot Registration
*
* Handles registration of slot layouts with the layout store for hit testing.
* This module manages the state mutation side of slot layout management,
* while pure calculations are handled separately in SlotCalculations.ts.
*/
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
calculateInputSlotPos,
calculateOutputSlotPos
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { SlotLayout } from '@/renderer/core/layout/types'
import { getSlotKey } from './slotIdentifier'
/**
* Register slot layout with the layout store for hit testing
* @param nodeId The node ID
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @param position The slot position in graph coordinates
*/
function registerSlotLayout(
nodeId: string,
slotIndex: number,
isInput: boolean,
position: Point
): void {
const slotKey = getSlotKey(nodeId, slotIndex, isInput)
// Calculate bounds for the slot using LiteGraph's standard slot height
const slotSize = LiteGraph.NODE_SLOT_HEIGHT
const halfSize = slotSize / 2
const slotLayout: SlotLayout = {
nodeId,
index: slotIndex,
type: isInput ? 'input' : 'output',
position: { x: position[0], y: position[1] },
bounds: {
x: position[0] - halfSize,
y: position[1] - halfSize,
width: slotSize,
height: slotSize
}
}
layoutStore.updateSlotLayout(slotKey, slotLayout)
}
/**
* Register all slots for a node
* @param nodeId The node ID
* @param context The slot position context
*/
export function registerNodeSlots(
nodeId: string,
context: SlotPositionContext
): void {
// Register input slots
context.inputs.forEach((_, index) => {
const position = calculateInputSlotPos(context, index)
registerSlotLayout(nodeId, index, true, position)
})
// Register output slots
context.outputs.forEach((_, index) => {
const position = calculateOutputSlotPos(context, index)
registerSlotLayout(nodeId, index, false, position)
})
}

View File

@@ -1,310 +0,0 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, ref, toValue } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import { LLink } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { Point } from '@/lib/litegraph/src/interfaces'
import { LinkDirection } from '@/lib/litegraph/src/types/globalEnums'
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { LayoutChange } from '@/renderer/core/layout/types'
export function useLinkLayoutSync() {
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')
}
return {
// Canvas settings
renderMode: canvas.links_render_mode,
connectionWidth: canvas.connections_width,
renderBorder: canvas.render_connections_border,
lowQuality: canvas.low_quality,
highQualityRender: canvas.highquality_render,
scale: canvas.ds.scale,
linkMarkerShape: canvas.linkMarkerShape,
renderConnectionArrows: canvas.render_connection_arrows,
// State
highlightedLinks: new Set(Object.keys(canvas.highlighted_links)),
// Colors
defaultLinkColor: canvas.default_link_color,
linkTypeColors: (canvas.constructor as any).link_type_colors || {},
// Pattern for disabled links
disabledPattern: canvas._pattern
}
}
/**
* Recompute a single link and all its segments
*
* Note: This logic mirrors LGraphCanvas#renderAllLinkSegments but:
* - Works with offscreen context for event-driven updates
* - No visibility checks (always computes full geometry)
* - No dragging state handling (pure geometry computation)
*/
function recomputeLinkById(linkId: number): void {
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
// Get source and target nodes
const sourceNode = graph.getNodeById(link.origin_id)
const targetNode = graph.getNodeById(link.target_id)
if (!sourceNode || !targetNode) return
// Get slots
const sourceSlot = sourceNode.outputs?.[link.origin_slot]
const targetSlot = targetNode.inputs?.[link.target_slot]
if (!sourceSlot || !targetSlot) return
// Get positions
const startPos = getSlotPosition(sourceNode, link.origin_slot, false)
const endPos = getSlotPosition(targetNode, link.target_slot, true)
// Get directions
const startDir = sourceSlot.dir || LinkDirection.RIGHT
const endDir = targetSlot.dir || LinkDirection.LEFT
// Get reroutes for this link
const reroutes = LLink.getReroutes(graph, link)
// Build render context
const context = buildLinkRenderContext()
if (reroutes.length > 0) {
// Render segmented link with reroutes
let segmentStartPos = startPos
let segmentStartDir = startDir
for (let i = 0; i < reroutes.length; i++) {
const reroute = reroutes[i]
// Calculate reroute angle
reroute.calculateAngle(Date.now(), graph, [
segmentStartPos[0],
segmentStartPos[1]
])
// Calculate control points
const distance = Math.sqrt(
(reroute.pos[0] - segmentStartPos[0]) ** 2 +
(reroute.pos[1] - segmentStartPos[1]) ** 2
)
const dist = Math.min(Reroute.maxSplineOffset, distance * 0.25)
// Special handling for floating input chain
const isFloatingInputChain = !sourceNode && targetNode
const startControl: Readonly<Point> = isFloatingInputChain
? [0, 0]
: [dist * reroute.cos, dist * reroute.sin]
// Render segment to this reroute
adapter.renderLinkDirect(
canvas.ctx,
segmentStartPos,
reroute.pos,
link,
true, // skip_border
0, // flow
null, // color
segmentStartDir,
LinkDirection.CENTER,
context,
{
startControl,
endControl: reroute.controlPoint,
reroute,
disabled: false
}
)
// Prepare for next segment
segmentStartPos = reroute.pos
segmentStartDir = LinkDirection.CENTER
}
// Render final segment from last reroute to target
const lastReroute = reroutes[reroutes.length - 1]
const finalDistance = Math.sqrt(
(endPos[0] - lastReroute.pos[0]) ** 2 +
(endPos[1] - lastReroute.pos[1]) ** 2
)
const finalDist = Math.min(Reroute.maxSplineOffset, finalDistance * 0.25)
const finalStartControl: Readonly<Point> = [
finalDist * lastReroute.cos,
finalDist * lastReroute.sin
]
adapter.renderLinkDirect(
canvas.ctx,
lastReroute.pos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
LinkDirection.CENTER,
endDir,
context,
{
startControl: finalStartControl,
disabled: false
}
)
} else {
// No reroutes - render direct link
adapter.renderLinkDirect(
canvas.ctx,
startPos,
endPos,
link,
true, // skip_border
0, // flow
null, // color
startDir,
endDir,
context,
{
disabled: false
}
)
}
}
/**
* Recompute all links connected to a node
*/
function recomputeLinksForNode(nodeId: number): void {
const graph = toValue(graphRef)
if (!graph) return
const node = graph.getNodeById(nodeId)
if (!node) return
const linkIds = new Set<number>()
// Collect output links
if (node.outputs) {
for (const output of node.outputs) {
if (output.links) {
for (const linkId of output.links) {
linkIds.add(linkId)
}
}
}
}
// Collect input links
if (node.inputs) {
for (const input of node.inputs) {
if (input.link !== null && input.link !== undefined) {
linkIds.add(input.link)
}
}
}
// Recompute each link
for (const linkId of linkIds) {
recomputeLinkById(linkId)
}
}
/**
* 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)
if (!reroute) return
// Recompute all links that pass through this reroute
for (const linkId of reroute.linkIds) {
recomputeLinkById(linkId)
}
}
/**
* Start link layout sync with event-driven functionality
*/
function start(canvasInstance: LGraphCanvas): void {
canvasRef.value = canvasInstance
if (!canvasInstance.graph) return
// Initial computation for all existing links
for (const link of canvasInstance.graph._links.values()) {
if (link.id !== -1) {
recomputeLinkById(link.id)
}
}
// Subscribe to layout store changes
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = layoutStore.onChange(
(change: LayoutChange) => {
switch (change.operation.type) {
case 'moveNode':
case 'resizeNode':
recomputeLinksForNode(parseInt(change.operation.nodeId))
break
case 'batchUpdateBounds':
for (const nodeId of change.operation.nodeIds) {
recomputeLinksForNode(parseInt(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
}
}
)
}
function stop(): void {
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = undefined
canvasRef.value = undefined
}
tryOnScopeDispose(stop)
return {
start,
stop
}
}

View File

@@ -1,150 +0,0 @@
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'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { registerNodeSlots } from '@/renderer/core/layout/slots/register'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
function computeAndRegisterSlots(node: LGraphNode): void {
const nodeId = String(node.id)
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
// Fallback to live node values if layout not ready
const nodeX = nodeLayout?.position.x ?? node.pos[0]
const nodeY = nodeLayout?.position.y ?? node.pos[1]
const nodeWidth = nodeLayout?.size.width ?? node.size[0]
const nodeHeight = nodeLayout?.size.height ?? node.size[1]
// Ensure concrete slots & arrange when needed for accurate positions
node._setConcreteSlots()
const collapsed = node.flags.collapsed ?? false
if (!collapsed) {
node.arrange()
}
const context: SlotPositionContext = {
nodeX,
nodeY,
nodeWidth,
nodeHeight,
collapsed,
collapsedWidth: node._collapsed_width,
slotStartY: node.constructor.slot_start_y,
inputs: node.inputs,
outputs: node.outputs,
widgets: node.widgets
}
registerNodeSlots(nodeId, context)
}
export function useSlotLayoutSync() {
const unsubscribeLayoutChange = ref<() => void>()
const restoreHandlers = ref<() => void>()
/**
* Attempt to start slot layout sync with full event-driven functionality
* @param canvas LiteGraph canvas instance
* @returns true if sync was actually started, false if early-returned
*/
function attemptStart(canvas: LGraphCanvas): boolean {
// When Vue nodes are enabled, slot DOM registers exact positions.
// Skip calculated registration to avoid conflicts.
if (LiteGraph.vueNodesMode) {
return false
}
const graph = canvas?.graph
if (!graph) return false
// Initial registration for all nodes in the current graph
for (const node of graph.nodes) {
computeAndRegisterSlots(node)
}
// Layout changes → recompute slots for changed nodes
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = layoutStore.onChange((change) => {
for (const nodeId of change.nodeIds) {
const node = graph.getNodeById(parseInt(nodeId))
if (node) {
computeAndRegisterSlots(node)
}
}
})
// LiteGraph event hooks
const origNodeAdded = graph.onNodeAdded
const origNodeRemoved = graph.onNodeRemoved
const origTrigger = graph.onTrigger
const origAfterChange = graph.onAfterChange
graph.onNodeAdded = (node: LGraphNode) => {
computeAndRegisterSlots(node)
if (origNodeAdded) {
origNodeAdded.call(graph, node)
}
}
graph.onNodeRemoved = (node: LGraphNode) => {
layoutStore.deleteNodeSlotLayouts(String(node.id))
if (origNodeRemoved) {
origNodeRemoved.call(graph, node)
}
}
graph.onTrigger = (event) => {
if (
event.type === 'node:property:changed' &&
event.property === 'flags.collapsed'
) {
const node = graph.getNodeById(parseInt(String(event.nodeId)))
if (node) {
computeAndRegisterSlots(node)
}
}
// Chain to original handler
origTrigger?.(event)
}
graph.onAfterChange = (graph: any, node?: any) => {
if (node && node.id) {
computeAndRegisterSlots(node)
}
if (origAfterChange) {
origAfterChange.call(graph, graph, node)
}
}
// Store cleanup function
restoreHandlers.value = () => {
graph.onNodeAdded = origNodeAdded || undefined
graph.onNodeRemoved = origNodeRemoved || undefined
// Only restore onTrigger if Vue nodes are not active
// Vue node manager sets its own onTrigger handler
if (!LiteGraph.vueNodesMode) {
graph.onTrigger = origTrigger || undefined
}
graph.onAfterChange = origAfterChange || undefined
}
return true
}
function stop(): void {
unsubscribeLayoutChange.value?.()
unsubscribeLayoutChange.value = undefined
restoreHandlers.value?.()
restoreHandlers.value = undefined
}
tryOnScopeDispose(stop)
return {
attemptStart,
stop
}
}

View File

@@ -274,6 +274,7 @@ LGraph {
"filter": undefined,
"fixedtime": 0,
"fixedtime_lapse": 0.01,
"floatingLinksInternal": Map {},
"globaltime": 0,
"id": "b4e984f1-b421-4d24-b8b4-ff895793af13",
"iteration": 0,
@@ -284,6 +285,7 @@ LGraph {
"nodes_executedAction": [],
"nodes_executing": [],
"onTrigger": undefined,
"reroutesInternal": Map {},
"revision": 0,
"runningtime": 0,
"starttime": 0,