Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/adr/0003-crdt-based-layout-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,9 @@ serializable command shape every mutation goes through.

Entity geometry registers and unregisters with the entity that owns it
(`LGraph.add` / `LGraph.remove`) rather than being seeded per graph on renderer
entry. All three entity types key by `makeScopedLayoutKey(rootGraphId, id)`, so
a root graph's teardown is one `clearGraph`; graphs sharing that bucket drop
their entries individually through `unregisterAllGraphLayout`.
entry. All three entity types key by `makeScopedLayoutKey(rootGraphId, id)`, and
every graph — root or nested — drops its entries individually through
`unregisterAllGraphLayout`.

## Notes

Expand Down
12 changes: 5 additions & 7 deletions docs/architecture/node-data-store.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,11 @@ it as `configure()` applies the real values. This follows the precedent
already set by `LGraph.createReroute`.

Geometry now leaves the store the same way: `LGraph.remove` unregisters one
entry, and bulk teardown goes through one of two paths. A root graph's `clear`
calls `layoutStore.clearGraph(graphId)`, beside the five peer stores cleared
there, because layout is keyed by `rootGraphId` and so has a bucket to wipe.
Graphs that share the root's bucket — subgraphs, unconfigured graphs, and the
orphaned subgraph release — call `unregisterAllGraphLayout` to drop entries
individually, mirroring `unregisterAllNodeStates` exactly. Both live in a
dedicated module so no teardown path re-derives the store writes by hand.
entry, and every bulk teardown — a root graph's `clear`, subgraphs,
unconfigured graphs, and the orphaned subgraph release — calls
`unregisterAllGraphLayout` to drop entries individually, mirroring
`unregisterAllNodeStates` exactly. It lives in a dedicated module so no
teardown path re-derives the store writes by hand.

`useVueNodeLifecycle` is gone; `GraphCanvas` owns only the Layout↔LiteGraph
sync lifecycle, and while the Vue renderer is on it drops view-scoped slot and
Expand Down
3 changes: 1 addition & 2 deletions src/composables/graph/useArrangeNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ interface ArrangeOptions {

export function useArrangeNodes() {
const { selectedNodes, hasMultipleSelection } = useSelectionState()
const mutations = useLayoutMutations()
const mutations = useLayoutMutations(LayoutSource.Canvas)
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()

Expand All @@ -180,7 +180,6 @@ export function useArrangeNodes() {
const updates = computeArrangement(selectedNodes.value, layout, gap)
if (updates.length === 0) return

mutations.setSource(LayoutSource.Canvas)
mutations.batchMoveNodes(rootGraphId, updates)
app.canvas?.setDirty(true, true)
if (captureUndo) {
Expand Down
46 changes: 46 additions & 0 deletions src/lib/litegraph/src/LGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodeLifecycleEvent } from '@/lib/litegraph/src/infrastructure/LGraphEventMap'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import {
LGraph,
LGraphGroup,
Expand Down Expand Up @@ -1723,6 +1725,29 @@ describe('node layout registration', () => {
).toBeNull()
})

it('adopts existing store geometry when added', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.id = toNodeId(42)
node.pos = [10, 20]
node.size = [100, 80]
useLayoutMutations(LayoutSource.Canvas).createNode(
graph.rootGraph.id,
node.id,
{
position: { x: 300, y: 400 },
size: { width: 220, height: 160 },
zIndex: 1,
visible: true
}
)

graph.add(node)

expect([...node.pos]).toEqual([300, 400])
expect([...node.size]).toEqual([220, 160])
})

function zIndexOf(graph: LGraph, node: LGraphNode): number {
const zIndex = layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id)
.value?.zIndex
Expand Down Expand Up @@ -1771,6 +1796,27 @@ describe('node layout registration', () => {
layoutStore.getNodeLayoutRef(graph.rootGraph.id, node.id).value
).not.toBeNull()
})

it('clears ownership despite a mutating listener that throws', async () => {
const graph = new LGraph()
const graphId = graph.id
const node = new LGraphNode('test')
graph.add(node)
await Promise.resolve()

vi.spyOn(console, 'error').mockImplementation(() => {})
const stop = layoutStore.onGeometryChange(() => {
node.pos = [500, 600]
throw new Error('listener failure')
})

expect(() => graph.clear()).not.toThrow()
await vi.waitFor(() => expect([...node.pos]).toEqual([500, 600]))
stop()

node.pos = [700, 800]
expect(layoutStore.getNodeLayout(graphId, node.id)).toBeNull()
})
})

describe('graph teardown drops layout entries', () => {
Expand Down
36 changes: 21 additions & 15 deletions src/lib/litegraph/src/LGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import {
canvasLayoutMutations,
registerGroupLayout,
registerNodeLayout,
unregisterAllGraphLayout,
unregisterNodeLayout
} from '@/renderer/core/layout/operations/graphLayoutRegistration'
attachGroupLayout,
attachNodeLayout,
detachAllGraphLayout,
detachGroupLayout,
detachNodeLayout,
detachRerouteLayout,
materializeRerouteLayout
} from '@/renderer/core/layout/operations/graphLayoutAttachment'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { toLinkId } from '@/types/linkId'
import { isFloatingTopology } from '@/types/linkTopology'
Expand Down Expand Up @@ -253,7 +255,7 @@ function teardownOwnedGraphs(owner: LGraph): void {
unregisterNodeState(node)
node.graph = null
}
unregisterAllGraphLayout(owner)
detachAllGraphLayout(owner, { removeLayouts: !owner.isRootGraph })
}
}

Expand Down Expand Up @@ -578,7 +580,6 @@ export class LGraph
useNodeDataStore().clearGraph(graphId)
layoutStore.clearGraph(graphId)
}

this.id = this.isRootGraph ? createUuidv4() : zeroUuid
this.revision = 0

Expand Down Expand Up @@ -1147,7 +1148,7 @@ export class LGraph
this.setDirtyCanvas(true)
this.change()
node.graph = this
registerGroupLayout(this, node)
attachGroupLayout(this, node, { adoptExisting: true })
this.incrementVersion()
return
}
Expand Down Expand Up @@ -1202,7 +1203,7 @@ export class LGraph

// Keep after onNodeAdded so its deferred hooks run before these writes
// flush Vue.
registerNodeLayout(this, node)
attachNodeLayout(this, node, { adoptExisting: true })
this.incrementVersion()

this.setDirtyCanvas(true)
Expand Down Expand Up @@ -1236,7 +1237,7 @@ export class LGraph
if (index != -1) {
this._groups.splice(index, 1)
}
canvasLayoutMutations().deleteGroup(this.rootGraph.id, node.id)
detachGroupLayout(node)
node.graph = undefined
this.incrementVersion()
this.setDirtyCanvas(true, true)
Expand Down Expand Up @@ -1305,7 +1306,7 @@ export class LGraph
unregisterAllLinkTopologies(subgraph)
unregisterAllRerouteChains(subgraph)
unregisterAllNodeStates(subgraph)
unregisterAllGraphLayout(subgraph)
detachAllGraphLayout(subgraph)
this.rootGraph.subgraphs.delete(subgraph.id)
}
}
Expand All @@ -1314,7 +1315,7 @@ export class LGraph
node.onRemoved?.()

unregisterNodeState(node)
unregisterNodeLayout(this, node)
detachNodeLayout(node)

node.graph = null
this.incrementVersion()
Expand Down Expand Up @@ -1665,6 +1666,11 @@ export class LGraph
if (existing) return existing === reroute
if (!registerRerouteChain(this, reroute)) return false
this.reroutesInternal.set(reroute.id, reroute)
if (materializeRerouteLayout(this, reroute) !== 'applied') {
this.reroutesInternal.delete(reroute.id)
unregisterRerouteChain(reroute)
return false
}
return true
}

Expand All @@ -1678,7 +1684,7 @@ export class LGraph
if (!reroute) return
this.reroutesInternal.delete(id)
unregisterRerouteChain(reroute)
canvasLayoutMutations().deleteReroute(this.rootGraph.id, id)
detachRerouteLayout(reroute)
}

/**
Expand Down Expand Up @@ -2610,7 +2616,7 @@ export class LGraph
if (!data) return
data = normalizeConfiguredTopology(data)
if (options.clearGraph) this.clear()
else unregisterAllGraphLayout(this)
else detachAllGraphLayout(this)

this._configureBase(data)

Expand Down
11 changes: 6 additions & 5 deletions src/lib/litegraph/src/LGraphCanvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
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 { canvasLayoutMutations } from '@/renderer/core/layout/operations/graphLayoutRegistration'
import { canvasLayoutMutations } from '@/renderer/core/layout/operations/graphLayoutAttachment'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
Expand Down Expand Up @@ -4348,11 +4348,12 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}))

const rootGraphId = graph.rootGraph.id
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(rootGraphId, newPositions)
layoutStore.batchUpdateNodeBounds(rootGraphId, newPositions, {
source: LayoutSource.Canvas
})

// Bring cloned/pasted nodes to front so they render above the originals
const { setNodeZIndex } = useLayoutMutations()
const { setNodeZIndex } = useLayoutMutations(LayoutSource.Canvas)
for (const { nodeId } of newPositions) {
setNodeZIndex(rootGraphId, nodeId, layoutStore.allocateZIndex())
}
Expand Down Expand Up @@ -4987,7 +4988,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const i = graph._nodes.indexOf(node)
if (i == -1) return

canvasLayoutMutations().bringNodeToFront(graph.rootGraph.id, node.id)
canvasLayoutMutations.bringNodeToFront(graph.rootGraph.id, node.id)

graph._nodes.splice(i, 1)
graph._nodes.push(node)
Expand Down
8 changes: 6 additions & 2 deletions src/lib/litegraph/src/LGraphGroup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { GroupId } from '@/types/groupId'
import { toGroupId } from '@/types/groupId'
import * as colorUtil from '@/utils/colorUtil'
Expand Down Expand Up @@ -285,7 +286,10 @@ describe('group layout in layoutStore', () => {
test('keeps geometry locally when the store entry is gone', () => {
const graph = new LGraph()
const group = addedGroup(graph, toGroupId(809))
useLayoutMutations().deleteGroup(graph.rootGraph.id, group.id)
useLayoutMutations(LayoutSource.Canvas).deleteGroup(
graph.rootGraph.id,
group.id
)

group.pos = [11, 22]

Expand Down Expand Up @@ -379,7 +383,7 @@ describe('group layout in layoutStore', () => {
type: 'setGroupBounds',
actor: 'test',
timestamp: 1,
source: layoutStore.getCurrentSource(),
source: LayoutSource.Canvas,
entity: 'group',
graphId: graph.rootGraph.id,
groupId: group.id,
Expand Down
13 changes: 2 additions & 11 deletions src/lib/litegraph/src/LGraphGroup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { setGroupBoundsLayout } from '@/renderer/core/layout/operations/graphLayoutAttachment'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { GroupId } from '@/types/groupId'
import { toGroupId } from '@/types/groupId'
import { hexToRgb, luminance, readableTextColor } from '@/utils/colorUtil'
Expand Down Expand Up @@ -32,8 +31,6 @@ import {
} from './measure'
import type { ISerialisedGroup } from './types/serialisation'

const layoutMutations = useLayoutMutations()

export interface IGraphGroupFlags extends Record<string, unknown> {
pinned?: true
}
Expand Down Expand Up @@ -168,13 +165,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
this.bounds.set([x, y, width, height])
if (!this.graph || this.id === -1) return

layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.setGroupBounds(
this.graph.rootGraph.id,
this.id,
{ x, y },
{ width, height }
)
setGroupBoundsLayout(this, { x, y }, { width, height })
this.syncBoundsFromStore()
}

Expand Down
Loading
Loading