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 *.json text eol=lf
*.mjs text eol=lf *.mjs text eol=lf
*.mts text eol=lf *.mts text eol=lf
*.snap text eol=lf
*.ts text eol=lf *.ts text eol=lf
*.vue text eol=lf *.vue text eol=lf
*.yaml text eol=lf *.yaml text eol=lf

View File

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

View File

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

View File

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