Compare commits

...

4 Commits

Author SHA1 Message Date
Alexander Brown
368b5bedd4 ECS: derive slot floating links; reroute cleanup follow-ups (#13451)
## Summary

Follow-up cleanups from the reroute chain store work (#13449): deletes
the `slot._floatingLinks` mirror in favor of endpoint derivation, fixes
two position/endpoint integrity gaps the mirror had masked, and
restructures the subgraph-unpack chain stitching.

Stacked on #13449.

## Changes

- **What**:
- `slot._floatingLinks` Sets deleted — a floating link's attachment is
fully encoded in its own endpoints, so `slotFloatingLinks(network, side,
nodeId, slot)` derives it; ~25 write sites across
`addFloatingLink`/`removeFloatingLink`, `setFloatingLinkOrigin`, and
every `FloatingRenderLink` connect method are gone
- `moveInputLink`/`moveOutputLink` gain the `node` param their
`dragNewFrom*` siblings already take
- `FloatingRenderLink.connectToSubgraphOutput` now writes the link's
**target** end (it wrote `origin_id = SUBGRAPH_OUTPUT_ID`,
clobbering/skewing endpoints — masked by the mirror)
- `removeInput`/`removeOutput` renumber floating-link slot indices
alongside real links (stale indices previously persisted into serialized
floating links)
- `Reroute.snapToGrid` mirrors into the layout store like `move()` does
(Vue hit-testing read a stale spatial index after snapped drags)
- Subgraph-unpack reroute chain stitching rewritten as
collect-then-stitch (two segment walks + one generic pointer pass,
replacing three interleaved walk-and-write loops)

## Review Focus

- Scoped out with written assessments: `input.link`/`output.links`
mirror removal (roadmap-scale `SlotConnection` extraction, ~530
accesses) and reroute position ownership inversion (layout store only
seeds the active graph, no writeback)
- Corrupt-data error paths in unpack stitching are normalized to
per-link skip (the old code broke the whole loop on some
internal-segment failures)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:04:19 -07:00
Alexander Brown
3e458f5e6f ECS: Reroute chain store with derived link membership (#13449)
## Summary

Extracts reroute connectivity into a dedicated `rerouteStore` per ADR
0008: chain state (`parentId`, floating marker) is stored by reference,
and link membership (`linkIds`/`floatingLinkIds`) is derived from the
links' `parentId` chains instead of being hand-maintained at ~10 write
sites.

Stacked on #13436 (link topology store); design record in
`docs/architecture/reroute-chain-store.md`.

## Changes

- **What**:
- `LLink._state` now holds the link store's reactive proxy (BaseWidget
pattern), so `link.parentId` writes are tracked by Vue
- New `rerouteStore`: root-scoped buckets keyed by `RerouteId`,
`RerouteChain {id, parentId?, floating?}` registered by reference;
`Reroute` class reads/writes through the store proxy
- Membership derived via a per-graph computed reverse index over link
topologies + chain states; `Reroute.linkIds`/`floatingLinkIds` become
readonly derived accessors
- Deleted: all manual membership mutation sites,
`Reroute.validateLinks`, `Reroute.removeLink`, `Reroute.update`'s
linkIds param
- `LGraph._addReroute`/`_removeReroute` chokepoints mirror
`_addLink`/`_removeLink`
- Load-time reroute-id dedup across sibling subgraph definitions
(mirrors node-id dedup; reroute ids must now be per-root-unique)
- Serialization emits `linkIds` in ascending link-id order; goldens
added for byte-identical round-trip
- **Breaking**: `Reroute.linkIds`/`floatingLinkIds` are now
`ReadonlySet` accessors — external mutation of these sets no longer
affects membership (the chain is the single source of truth).
`Reroute.validateLinks`/`removeLink` removed.

## Review Focus

- `LLink.disconnect` now unregisters the link before pruning empty
reroutes, so derived counts exclude it
- `dropOnReroute` resolves the drop target's source output once before
connecting moved links — re-anchoring the first link's chain changes
derived membership immediately (`LinkConnector.ts`)
- Workflows whose stored `linkIds` contradict their chains are repaired
rather than preserved (design decision 5; no shim)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-07-10 12:35:12 -07:00
Alexander Brown
9c7af27a52 ECS: Link topology store (single source of truth for link data) (#13436)
## Summary

Make a Pinia store (`useLinkStore`) the single source of truth for graph
link topology (ADR-0008 / ECS), mirroring `widgetValueStore`. `LLink`
topology fields become accessors over one `_state` object registered
into the store **by reference** — one copy, no mirror, no sync bridge.

## Changes

- **What**:
- New `useLinkStore` (`src/stores/linkStore.ts`) — **keyed by target
input slot**. At most one live link can target a given input slot
(litegraph disconnects the previous link before connecting a new one),
and every consumer queries by target, so the dominant query ("is this
input connected, and by what?") is one map lookup. Public surface is 6
actions (`registerLink`, `updateEndpoint`, `deleteLink`,
`isInputSlotConnected`, `getInputSlotLink`, `clearGraph`).
- Links without a unique target live in a per-graph side collection:
floating links (either endpoint unassigned; several can share an input
slot) and links into `SUBGRAPH_OUTPUT_ID` (a constant shared by every
subgraph in a root bucket). Neither is queried by target. Floating links
never answer `isInputSlotConnected`, matching `input.link` semantics.
- `LLink` topology backed by a single `_state: LinkTopology`; endpoint
setters delegate to `updateEndpoint` (displace → patch → re-place) when
registered. `updateEndpoint`/`deleteLink` take the topology by reference
and are identity-checked; `LLink._graphId` is set only while the link
actually holds a registration.
- `LGraph._addLink` / `_removeLink` chokepoints: every `_links` map
add/remove funnels through them (both `configure` loops, subgraph
reconnect, duplicate-link purge, `linkFixer`), keeping link-store and
layout entries in sync. Subgraph-definition removal and
non-root/unconfigured `clear()` unregister their links.
- Topology registered at every litegraph create site (`connectSlots`,
both `configure` loops, `addFloatingLink`, subgraph connect) and cleaned
up on `disconnect` / `removeFloatingLink` / `clear` / node-delete
cascade.
- Deleted the `layoutStore.ylinks` connectivity mirror +
`layoutMutations.createLink`/`deleteLink` +
`Create/DeleteLinkOperation`; node removal disconnects links before
`onNodeRemoved` fires, so layout cleanup needs no connectivity mirror or
layout→link cross-store call.
- Migrated the widget-disabled hot path (`WidgetItem.isLinked`,
`AppModeWidgetList`, `buildSlotMetadata`) to O(1) `isInputSlotConnected`
/ `getInputSlotLink`; removed the now-redundant `node:slot-links:changed
→ refreshNodeInputs` re-projection.
- Serialization round-trip goldens (plain / reroute-chain / floating /
subgraph). Because the store no longer keys by link id, subgraph link-id
collisions are irrelevant to it and **workflows load without any link-id
rewriting** (the node-id dedup for widget-store keys is pre-existing and
unchanged).
- **Breaking**: `LLink` topology fields (`origin_id`, `origin_slot`,
`target_id`, `target_slot`, `type`, `parentId`) are now accessors over
`_state`; `parentId` moves from own-property to prototype accessor
(`hasOwnProperty('parentId')` now returns `false`). `asSerialisable()`
output is byte-identical (guarded by goldens).

## Review Focus

- **Register-by-reference**: `LLink._state` is the exact object the
store holds — no second copy, no sync function. Verify no path writes
topology to two places.
- **Target-keying invariant**: one live link per input slot is enforced
by litegraph (`connectSlots` disconnects first); the store's side
collection covers exactly the links that violate or escape it (floating,
subgraph-output-node targets).
- Endpoint setters must delegate fully to `updateEndpoint` when
registered and mutate `_state` directly only when unregistered.
- Lifecycle completeness: every create path registers, every destroy
path unregisters — `_addLink`/`_removeLink` are the chokepoints.
- **Reroutes explicitly deferred.** Remaining `input.link` connectedness
*reads* (`nodeDataUtils`, `useNodePricing`, GLSL preview) are strangler
residue for a follow-up — not in scope here.

Stacked on #13322 — base is `drjkl/safe-widget-cleanup`.

## Migration note (extension authors)

`LLink` topology fields (`id`, `origin_id`, `origin_slot`, `target_id`,
`target_slot`, `type`, `parentId`) are now accessors over an internal
`_state` object, so they are non-enumerable on live instances.
`Object.keys(link)`, `JSON.stringify(link)`, `{...link}`, and
`structuredClone(link)` no longer expose them. To copy or serialize a
link, use `link.asSerialisable()` — direct field reads
(`link.origin_id`) are unchanged. Persisted workflow JSON is unaffected
(byte-identical, covered by goldens).

## Screenshots

Manual render-parity checklist (run against a ~200-node workflow): links
render; connect/disconnect updates live; undo/redo; subgraph nav. E2E
`nodeSelection.spec.ts` passes 13/13 including "a linked widget is
disabled". Note: 8 `linkInteraction.spec.ts` screenshot diffs (~0.01
ratio) observed — no commit in this branch touches link
geometry/coordinates/draw order, so these are
pre-existing/environmental; confirm against base before un-drafting.

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 21:21:58 -07:00
Alexander Brown
14d55eb305 ECS: Moving widgets out of the Node data (#13322)
## Summary

Move Vue widget render-only metadata out of the graph node manager and
into dedicated widget render state alongside widget values.

## Changes

- **What**: Adds `WidgetRenderState` in `widgetValueStore`, removes
`SafeWidgetData` from `useGraphNodeManager`, and has Vue widget
rendering compose from raw LiteGraph widgets plus store-backed
value/render state.
- **What**: Updates promoted widget, app mode, parameter panel, preview,
tooltip, and widget tests for the new render-state boundary.
- **What**: Hardens related browser tests against branded id typing and
brittle default workflow node ids.

## Review Focus

Please look closely at the ownership boundary between raw LiteGraph
widgets, `WidgetState`, and `WidgetRenderState`, especially promoted
widget metadata and linked-widget rendering.

## Validation

- `pnpm typecheck`
- `pnpm typecheck:browser`
- focused widget/node unit tests
- focused Playwright check for `Should display added widgets`

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: AustinMroz <austin@comfy.org>
2026-07-09 10:42:30 -07:00
107 changed files with 5207 additions and 3016 deletions

View File

@@ -6,7 +6,7 @@
"nodes": [
{
"id": 3,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [10, 250],
"size": [400, 200],
"flags": {},
@@ -20,7 +20,7 @@
},
{
"id": 4,
"type": "outer-subgraph-with-promoted-missing-model",
"type": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
@@ -38,7 +38,7 @@
"definitions": {
"subgraphs": [
{
"id": "outer-subgraph-with-promoted-missing-model",
"id": "4e7c1a2b-3d5f-4a6b-8c9d-0e1f2a3b4c5d",
"version": 1,
"state": {
"lastGroupId": 0,
@@ -71,7 +71,7 @@
"nodes": [
{
"id": 2,
"type": "inner-subgraph-with-promoted-missing-model",
"type": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"pos": [250, 180],
"size": [400, 200],
"flags": {},
@@ -105,7 +105,7 @@
]
},
{
"id": "inner-subgraph-with-promoted-missing-model",
"id": "5f8d2b3c-4e6a-4b7c-9d0e-1f2a3b4c5d6e",
"version": 1,
"state": {
"lastGroupId": 0,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1270,3 +1270,57 @@ test(
})
}
)
test('Floating reroutes', { tag: '@vue-nodes' }, async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
const previewNodePos = { position: { x: 800, y: 200 } }
await comfyPage.searchBoxV2.addNode('Preview Image', previewNodePos)
const previewNode =
await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
await test.step('Create floating reroute', async () => {
const reroutePos = { targetPosition: { x: 700, y: 400 } }
await previewNode
.getSlot('images')
.first()
.dragTo(comfyPage.canvas, reroutePos)
await comfyPage.contextMenu.clickLitegraphMenuItem('Add Reroute')
await comfyPage.searchBoxV2.addNode('Load Image')
})
await test.step('Connect node on top of floating link', async () => {
const loadNode = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadNode
.getSlot('IMAGE')
.first()
.dragTo(previewNode.getSlot('images').first())
})
await test.step('Create node from floating reroute', async () => {
await comfyPage.canvas.dragTo(comfyPage.canvas, {
sourcePosition: { x: 680, y: 400 },
targetPosition: { x: 500, y: 500 }
})
await comfyPage.contextMenu.clickLitegraphMenuItem('LoadImage')
})
await expect
.poll(
() =>
comfyPage.page.evaluate(() => {
if (!graph || graph.links.size !== 1) return 'invalid link count'
if (graph.reroutes.size !== 1) return 'invalid reroutes count'
const linkId = graph.nodes.find((n) => n.title === 'Preview Image')
?.inputs[0].link
if (!linkId) return 'failed to resolve link id'
const rerouteId = graph.getLink(linkId)?.parentId
if (!rerouteId) return 'failed to resolve reroute id'
return !graph.reroutes.has(rerouteId) && 'reroute does not exist'
}),
'old link is disconnected, reroute is part of new connection'
)
.toBe(false)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -8,25 +8,32 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
comfyPage,
comfyMouse
}) => {
let legacyNodeId = toNodeId(10)
await test.step('setup', async () => {
await comfyPage.nodeOps.addNode('DevToolsNodeWithLegacyWidget', undefined, {
x: 0,
y: 0
})
await comfyPage.appMode.enterAppModeWithInputs([['10', 'legacy_widget']])
const legacyNode = await comfyPage.nodeOps.addNode(
'DevToolsNodeWithLegacyWidget',
undefined,
{
x: 0,
y: 0
}
)
legacyNodeId = legacyNode.id
await comfyPage.appMode.enterAppModeWithInputs([
[String(legacyNodeId), 'legacy_widget']
])
})
const getWidth = () =>
comfyPage.page.evaluate(
(nodeId) => graph!.getNodeById(nodeId)!.widgets![0].width ?? 0,
toNodeId(10)
)
const getWidth = async () =>
(await comfyPage.appMode.linearWidgets.locator('canvas').boundingBox())
?.width ?? 0
await test.step('Mouse clicks resolve to button regions', async () => {
const legacyWidget = comfyPage.appMode.linearWidgets.locator('canvas')
const { width, height } = (await legacyWidget.boundingBox())!
const nodeRef = await comfyPage.nodeOps.getNodeRefById(10)
const nodeRef = await comfyPage.nodeOps.getNodeRefById(legacyNodeId)
const legacyWidgetRef = await nodeRef.getWidget(0)
expect(await legacyWidgetRef.getValue()).toBe(0)
await legacyWidget.click({ position: { x: 20, y: height / 2 } })
@@ -36,8 +43,8 @@ test('@vue-nodes In App Mode, widget width updates with panel size', async ({
})
await test.step('Resize to update width', async () => {
await expect.poll(getWidth).toBeGreaterThan(0)
const initialWidth = await getWidth()
expect(initialWidth).toBeGreaterThan(0)
const gutter = comfyPage.page.getByRole('separator')

View File

@@ -3,31 +3,43 @@ import {
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import type { TestGraphAccess } from '@e2e/types/globals'
import { toNodeId } from '@/types/nodeId'
test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
test('Should display added widgets', async ({ comfyPage }) => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
const nodeId = toNodeId(
await comfyPage.page.evaluate(() => {
const node = window.app!.graph.nodes.find(
(node) => (node.widgets?.length ?? 0) === 1
)
if (!node) throw new Error('Node with one widget not found')
return String(node.id)
})
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
const widgets = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-widget')
await expect(widgets).toHaveCount(1)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_a', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(2)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_b', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
}, nodeId)
await expect(widgets).toHaveCount(3)
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.graph.getNodeById(nodeId)
if (!node) throw new Error(`Node ${nodeId} not found`)
node.addWidget('text', 'extra_widget_c', '', () => {})
})
await expect(loadCheckpointNode).toHaveCount(4)
}, nodeId)
await expect(widgets).toHaveCount(4)
})
test('Should hide removed widgets', async ({ comfyPage }) => {

View File

@@ -21,6 +21,22 @@ text below says "the World," read "the set of dedicated stores"; where it shows
`world.getComponent(id, Component)`, read the matching store getter (for
example `widgetValueStore.getWidget(widgetId)`).
### Amendment (2026-07-05, PRs 13436/13449)
Two stores joined the dedicated-store set: `linkStore` (link topology,
keyed by target input slot in root-graph-scoped buckets — see
[Link Topology Store](../architecture/link-topology-store.md)) and
`rerouteStore` (reroute chain state with link membership derived from
the links' `parentId` chains — see
[Reroute Chain Store](../architecture/reroute-chain-store.md)). Both
follow the proxy-returning registration pattern established by
`BaseWidget`/`widgetValueStore`: the store bucket is a `reactive(Map)`,
registration inserts the class's state object by reference and the class
adopts the reactive proxy read back from the bucket, so class field
writes are tracked without an action chokepoint. The `layoutStore` link
connectivity mirror and the `slot._floatingLinks` sets were deleted in
the same work; the layout store now holds geometry only.
## Context
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
@@ -115,6 +131,15 @@ Components are plain data objects — no methods, no back-references to parent e
| `LinkVisual` | `color`, `path`, `_pos` (center point) |
| `LinkState` | `_dragging`, `data` |
> **Amended (2026-07-05):** `LinkEndpoints` shipped as
> `LinkTopology { id, originNodeId, originSlot, targetNodeId,
targetSlot, type, parentId? }` in a dedicated `linkStore`, keyed by
> **target input slot** (not link id) in root-graph-scoped buckets, with
> floating and subgraph-output links in an unkeyed side set. `LLink`
> reads through the store's reactive proxy (`_state`). See
> [Link Topology Store](../architecture/link-topology-store.md).
> `LinkVisual` and `LinkState` remain unextracted.
#### Subgraph (Node Components)
A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
@@ -140,6 +165,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `SlotConnection` | `link` (input) or `links[]` (output), `widget` locator |
| `SlotVisual` | `pos`, `boundingRect`, `color_on`, `color_off`, `shape` |
> **Amended (2026-07-05):** the input side of `SlotConnection` is
> subsumed by the `linkStore` key — the input-slot→link mapping _is_ the
> store's primary index (`isInputSlotConnected` / `getInputSlotLink`).
> The `slot._floatingLinks` sets were deleted; floating-link attachment
> is derived from the links' own endpoints (`slotFloatingLinks`). The
> `input.link` / `output.links` class mirrors remain un-migrated.
#### Reroute
| Component | Data (from `Reroute`) |
@@ -148,6 +180,13 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `RerouteLinks` | `parentId`, input/output link IDs |
| `RerouteVisual` | `color`, badge config |
> **Amended (2026-07-04):** `RerouteLinks` was superseded during design
> review. The stored component is chain state only —
> `RerouteChain { parentId, floating? }` — and link membership
> (`linkIds` / `floatingLinkIds`) is derived from the links' `parentId`
> chains rather than stored. See
> [Reroute Chain Store](../architecture/reroute-chain-store.md).
#### Group
| Component | Data (from `LGraphGroup`) |
@@ -271,6 +310,9 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [Link Topology Store](../architecture/link-topology-store.md) | Design record for the `linkStore` — target-input-slot keying, root-scoped buckets, registration protocol |
| [Reroute Chain Store](../architecture/reroute-chain-store.md) | Design record for the `rerouteStore` — chain state, derived link membership, load-time id dedup |
| [Domain Glossary](../architecture/domain-glossary.md) | Canonical vocabulary for links, reroutes, chains, and membership |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
| [Appendix: ECS Pattern Survey](../architecture/appendix-ecs-pattern-survey.md) | Survey of bitECS, miniplex, koota, ECSY, Thyseus, and Bevy — patterns adopted, departed, when to revisit |

View File

@@ -0,0 +1,33 @@
# Domain Glossary
Canonical vocabulary for the graph domain. Terms are added as they are
resolved during design work; keep entries implementation-free. Intended
to grow into a proper reference document.
Design records that rely on this vocabulary:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md),
[ADR 0008](../adr/0008-entity-component-system.md).
## Links & Reroutes
- **Link** — a directed data connection from one node's output slot to
another node's input slot. At most one live link targets a given input
slot.
- **Floating link** — a link with exactly one attached endpoint, kept
alive so a reroute chain survives disconnection. The unattached end is
unassigned.
- **Reroute** — a visual waypoint that a link's rendered path travels
through. Purely organisational; never affects data flow. A reroute's
identity is unique within a workflow, subgraphs included.
- **Reroute chain** — the ordered sequence of reroutes a link passes
through, from the node output toward the input. Each reroute names its
upstream neighbour via _parent_; the link names the chain's most
downstream reroute (the **terminal reroute**).
- **Link membership (of a reroute)** — the set of links whose chains pass
through that reroute. Membership is _defined by_ the chains: a link is a
member of exactly the reroutes on the chain walked from its terminal
reroute upstream. It is never authored independently of the chain.
- **Floating slot marker** — the annotation on the last reroute of a
floating chain recording which side (input or output) the chain still
faces.

View File

@@ -2,7 +2,7 @@
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from [ADR 0008](../adr/0008-entity-component-system.md).
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Link topology and reroute chain state shipped later into `linkStore` (PR 13436, keyed by target input slot in root-graph-scoped buckets — see [link-topology-store.md](link-topology-store.md)) and `rerouteStore` (PR 13449, membership derived from the links' `parentId` chains — see [reroute-chain-store.md](reroute-chain-store.md)); `layoutStore` keeps link/reroute geometry only. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
@@ -66,6 +66,7 @@ sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LM as useLayoutMutations()
participant LKS as linkStore
participant LS as layoutStore
participant WVS as widgetValueStore
participant NOS as nodeOutputStore
@@ -73,12 +74,14 @@ sequenceDiagram
Caller->>CS: removeNode(nodeId)
CS->>LS: read node links (incoming + outgoing)
LS-->>CS: linkIds
CS->>LKS: read node links (incoming + outgoing)
LKS-->>CS: link topologies
loop each linkId
CS->>LM: deleteLink(linkId)
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
loop each link
CS->>LKS: unregister link topology
Note over CS,LKS: via the LGraph._removeLink chokepoint —<br/>map delete + store unregistration
CS->>LS: drop link geometry
Note over LKS,LS: linkStore owns topology;<br/>layoutStore only drops geometry
end
loop each widget on node
@@ -93,15 +96,15 @@ sequenceDiagram
### Key Differences
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
| Aspect | Current | ECS |
| ------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates linkStore + layout/widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | linkStore unregistration per link (via `_removeLink`) + geometry drop per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore not cleaned up; link geometry still removed from LayoutStore | Coordinated: `deleteWidget`, linkStore unregister + `deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
## 2. Serialization
@@ -223,7 +226,7 @@ sequenceDiagram
end
opt has subgraph definitions
G->>G: deduplicateSubgraphNodeIds()
G->>G: deduplicateSubgraphNodeIds() + deduplicateSubgraphRerouteIds()
loop each subgraph (topological order)
G->>G: createSubgraph(data)
end
@@ -250,7 +253,7 @@ sequenceDiagram
end
G->>G: add floating links
G->>G: validate reroutes
G->>G: prune reroutes with derived totalLinks === 0
G->>G: _removeDuplicateLinks()
loop each serialized group
@@ -262,6 +265,8 @@ sequenceDiagram
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside `node.configure()`. Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
Note on load-time id hygiene: root `configure()` deduplicates **node ids** and **reroute ids** across sibling subgraph definitions before configuring them (`deduplicateSubgraphNodeIds` / `deduplicateSubgraphRerouteIds`), because both share root-graph-scoped store buckets. Link-id dedup is deliberately absent — the linkStore key is the target input slot, not the link id, so duplicate link ids across definitions cannot collide. Orphaned reroutes are pruned by the derived `totalLinks === 0` check; the old `validateLinks` set-repair is gone (membership is derived, so there is no stored set to drift).
### ECS Flow
```mermaid
@@ -532,7 +537,7 @@ sequenceDiagram
participant G as LGraph
participant L as LLink
participant R as Reroute
participant LS as LayoutStore
participant LKS as linkStore
Caller->>N1: connectSlots(output, targetNode, input)
@@ -545,24 +550,24 @@ sequenceDiagram
end
N1->>L: new LLink(++lastLinkId, type, ...)
N1->>G: _links.set(link.id, link)
N1->>LS: layoutMutations.createLink()
N1->>G: graph._addLink(link)
G->>LKS: register topology (keyed by target input slot)
Note over G,LKS: chokepoint: _links.set + linkStore registration
N1->>N1: output.links.push(link.id)
N1->>N2: input.link = link.id
loop each reroute in path
N1->>R: reroute.linkIds.add(link.id)
end
N1->>R: anchorRerouteChain(graph, link)
Note over N1,R: clears floating markers on the chain —<br/>membership is derived from link.parentId,<br/>no per-reroute linkIds writes
N1->>G: _version++
N1->>G: incrementVersion()
N1->>N1: onConnectionsChange?(OUTPUT, ...)
N1->>N2: onConnectionsChange?(INPUT, ...)
N1->>G: setDirtyCanvas()
N1->>G: afterChange()
```
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
Problems: the source node orchestrates everything — it reaches into the graph's link map (via the `_addLink` chokepoint), the target node's slot, and the version counter. Reroute membership no longer needs writes (derived via rerouteStore), but the slot mirrors (`output.links`, `input.link`) are still mutated by hand.
### ECS Flow
@@ -570,29 +575,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant LS as layoutStore
participant LM as useLayoutMutations()
participant LKS as linkStore
Caller->>CS: connect(outputSlot, inputSlot)
CS->>LS: read input slot link
CS->>LKS: getInputSlotLink(graphId, targetNodeId, targetSlot)
opt already connected
CS->>LM: deleteLink(existingLinkId)
CS->>LKS: unregister existing topology
end
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
CS->>LKS: register LinkTopology {<br/> id, originNodeId, originSlot,<br/> targetNodeId, targetSlot, type<br/>}
Note over CS,LKS: the target-slot key IS the input-side<br/>slot connection — no separate endpoint update.<br/>Reroute membership derives from parentId;<br/>rerouteStore needs no write
```
### Key Differences
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------ | ------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
| Aspect | Current | ECS |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
| Orchestrator | Source node (reaches into graph, target slots) | ConnectivitySystem (reads linkStore) |
| Side effects | `incrementVersion()`, `setDirtyCanvas()`, `afterChange()`, callbacks | topology registration — endpoints + change tracking included |
| Reroute handling | None needed — membership derived from `parentId` chains (rerouteStore); `anchorRerouteChain` only clears floating markers | Same — derived membership is already the target shape |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | Input side subsumed by the linkStore key; output side pending SlotConnection extraction |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste

View File

@@ -12,7 +12,8 @@ For verified accuracy of these documents, see
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
> string IDs, one store per concern (widget values, DOM widgets, layout, node
> outputs, subgraph navigation, preview exposure). The earlier "single unified
> outputs, subgraph navigation, preview exposure, link topology, reroute
> chains). The earlier "single unified
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
> was rejected. PR 12617 shipped the first stores against composite
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
@@ -113,14 +114,25 @@ narrow accessor surface. There is no single container that fronts all entities.
Shipped stores:
| Store | File |
| ------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
| Store | File |
| -------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
| `linkStore` ✅ PR 13436 | `src/stores/linkStore.ts` |
| `rerouteStore` ✅ PR 13449 | `src/stores/rerouteStore.ts` |
`linkStore` holds `LinkTopology` records (`src/types/linkTopology.ts`) keyed by
target input slot (`` `${targetNodeId}:${targetSlot}` ``) in root-graph-scoped
buckets — subgraphs share their root's bucket; floating links and links
targeting subgraph outputs live in a per-graph unkeyed side set. `rerouteStore`
holds `RerouteChain` records keyed by `RerouteId` in root-graph-scoped buckets;
link membership is not stored but derived from the links' `parentId` chains.
Design records: [Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
@@ -260,6 +272,14 @@ link-endpoint records from the relevant stores:
Does not perform mutations yet — just queries. Validates that store connectivity
data is complete and consistent with the class-based graph.
> **Status (2026-07-05):** The reroute-membership query shipped as `linkStore` +
> `rerouteStore` (PRs 13436, 13449): "what links pass through this reroute" is
> derived per root graph by a cached reverse index over the links' `parentId`
> chains, and input-side connectivity is one lookup via
> `linkStore.isInputSlotConnected()` / `getInputSlotLink()`. Remaining:
> slot mirrors (`input.link` / `output.links`), output-side queries, and
> execution order.
**Risk:** Low. Read-only system with equivalence tests.
---
@@ -311,6 +331,12 @@ the system knowing about the callback API.
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
compatibility API during Phase 4.
> **Status (2026-07-05):** Link and reroute store registration now funnels
> through canonical `LGraph` mutation chokepoints: `_addLink`/`_removeLink` and
> `_addReroute`/`_removeReroute` pair every map mutation with store
> (un)registration, and `clear()` / subgraph-definition GC unregister whole
> graphs. The callback contract above and slot-mirror extraction remain.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
@@ -596,13 +622,15 @@ state between these calls.
The dedicated stores use per-concern keying strategies:
| Store | Key Format |
| ------------------------- | ---------------------------------- |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
| Store | Key Format |
| ------------------------- | ------------------------------------------------------------------------------------ |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
| `linkStore` | `` `${targetNodeId}:${targetSlot}` `` (target input slot), root-graph-scoped buckets |
| `rerouteStore` | `RerouteId`, root-graph-scoped buckets |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
@@ -629,7 +657,8 @@ Phase 0c (doc fixes) ─────────┤── no dependencies betwe
Phase 1a (branded WidgetId) ── ✅ shipped (PR 12617)
Phase 1b (store state shapes) ─┐── depends on 1a
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
Phase 1c (dedicated stores) ──┘── widgetValueStore + 7 others shipped
(PR 12617; linkStore PR 13436; rerouteStore PR 13449)
Phase 2a (Position via layoutStore) ─┐── depends on 1c
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c

View File

@@ -18,7 +18,12 @@ Map&lt;WidgetId, WidgetValue&gt;"]
DomWidgetStore["domWidgetStore
Map&lt;WidgetId, DomWidgetState&gt;"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → layout"]
nodeId / linkId / rerouteId → geometry"]
LinkStore["linkStore
rootGraphId → targetNodeId:targetSlot
→ LinkTopology"]
RerouteStore["rerouteStore
rootGraphId → RerouteId → RerouteChain"]
NodeOutputStore["nodeOutputStore
Map&lt;nodeLocatorId, outputs&gt;"]
SubgraphNavStore["subgraphNavigationStore
@@ -39,7 +44,8 @@ preview exposure state"]
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LayoutStore
CS -->|reads/writes| LinkStore
CS -->|reads/writes| RerouteStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
@@ -63,20 +69,31 @@ subgraphId:nodeId"]
NID["nodeId (raw)"]
LID["linkId (raw)"]
RID["rerouteId (raw)"]
TIS["targetNodeId:targetSlot
(root-graph-scoped bucket)"]
end
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore| W
RID -->|layoutStore, rerouteStore| W
TIS -->|linkStore| W
```
`WidgetId = graphId:nodeId:name` is itself a branded string (see
`src/types/widgetId.ts`). `nodeLocatorId = subgraphId:nodeId` addresses node
outputs. `layoutStore` keys layout records by raw `nodeId` / `linkId` /
`rerouteId`. Each store enforces its own key shape; there is no single shared
entity-ID type across stores.
outputs. `layoutStore` keys geometry records by raw `nodeId` / `linkId` /
`rerouteId`. `linkStore` keys `LinkTopology` by **target input slot**
(`targetNodeId:targetSlot`) inside root-graph-scoped buckets — the link id is
NOT the key; at most one live link can target an input slot, so the target is
the natural primary key (see
[link-topology-store.md](link-topology-store.md)). Links without a unique
target (floating links, `SUBGRAPH_OUTPUT_ID` targets) live in a per-graph
unkeyed side set. `rerouteStore` keys `RerouteChain` by raw `rerouteId` in
root-graph-scoped buckets (see
[reroute-chain-store.md](reroute-chain-store.md)). Each store enforces its own
key shape; there is no single shared entity-ID type across stores.
Note: `graphId` is a scope identifier. It identifies which graph an entity
belongs to and forms the prefix of `WidgetId`. Subgraphs are nodes with a
@@ -177,14 +194,15 @@ target_id, target_slot, type"]
B5["resolve()"]
end
subgraph After["linkId-keyed components (layoutStore)"]
subgraph After["target-slot-keyed topology (linkStore) + unextracted state"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
A1["LinkTopology — SHIPPED
{ id, originNodeId, originSlot,
targetNodeId, targetSlot, type, parentId? }
keyed by targetNodeId:targetSlot"]
A2["LinkVisual — not yet extracted
{ color, path, centerPos }"]
A3["LinkState
A3["LinkState — not yet extracted
{ dragging, data }"]
end
@@ -198,6 +216,26 @@ targetId, targetSlot, type }"]
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
```
`LinkTopology` has shipped in `src/stores/linkStore.ts`: `LLink._state` IS the
store entry — the class fields are accessors over the store's reactive proxy,
so the store and the instance cannot disagree. Registration is first-wins with
identity-checked delete/update. See
[link-topology-store.md](link-topology-store.md) for the full design record.
`LinkVisual` and `LinkState` remain on the `LLink` class.
### Reroute: RerouteChain (shipped)
Reroutes follow the same pattern. `RerouteChain { id, parentId?, floating? }`
lives in `src/stores/rerouteStore.ts`, keyed by `RerouteId` in
root-graph-scoped buckets; `Reroute._chain` is the store entry. Link
membership (`Reroute.linkIds` / `floatingLinkIds`) is **not stored** — it is
derived per root graph by a cached computed reverse index walking the links'
`parentId` chains, replacing ~10 hand-maintained write sites and the
`validateLinks` set-repair. See
[reroute-chain-store.md](reroute-chain-store.md). Reroute _position_ is not
yet migrated: `Reroute.posInternal` remains the source of truth, with the
layout store holding a partial `{ id, position }` mirror.
### Widget: Before vs After
```mermaid
@@ -248,8 +286,8 @@ graph TD
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
Writes: LinkTopology (shipped),
SlotConnection (future), Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
@@ -312,9 +350,11 @@ graph LR
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkEndpoints"]
LE["LinkTopology
(linkStore — shipped)"]
LV["LinkVisual"]
SC["SlotConnection"]
SC["SlotConnection
(output side — future)"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
@@ -333,7 +373,7 @@ graph LR
LS -.->|read| WC
CS -->|write| LE
CS -->|write| SC
CS -.->|future write| SC
CS -->|write| Con
ES -.->|read| Con
@@ -349,6 +389,14 @@ graph LR
VS -.->|read| Con
```
ConnectivitySystem's `LinkEndpoints` write target is realized as
`LinkTopology` in `linkStore`. The input side of `SlotConnection`
(`input.link`) is subsumed by the linkStore key itself — "which link targets
this input slot" is the store's primary index
(`isInputSlotConnected` / `getInputSlotLink`) — though the `input.link` slot
mirror still exists on the class. The output side (`output.links[]`) remains
future extraction work.
## 4. Dependency Flow
### Before: Tangled References

View File

@@ -66,7 +66,7 @@ graph TD
Link -.->|"origin_id, target_id"| Node
Link -.->|"parentId"| Reroute
Slot -.->|"link / links[]"| Link
Reroute -.->|"linkIds"| Link
Reroute -.->|"linkIds (derived, rerouteStore)"| Link
Reroute -.->|"parentId"| Reroute
Group -.->|"_children Set"| Node
Group -.->|"_children Set"| Reroute
@@ -103,10 +103,14 @@ type: ISlotType"]
Link -.->|"parentId"| R1["Reroute A"]
R1 -.->|"parentId"| R2["Reroute B"]
R1 -.-|"linkIds Set"| Link
R2 -.-|"linkIds Set"| Link
R1 -.-|"linkIds (derived)"| Link
R2 -.-|"linkIds (derived)"| Link
```
`Reroute.linkIds` / `floatingLinkIds` are read-only accessors derived from the
links' own `parentId` chains by `rerouteStore` — membership is never stored
(see [reroute-chain-store.md](reroute-chain-store.md)).
### Subgraph Boundary Connections
```mermaid
@@ -142,14 +146,20 @@ graph TD
```mermaid
graph LR
Slot["Source Slot"] -->|"drag starts"| FL["Floating LLink
origin_id=-1 or target_id=-1"]
origin or target = UNASSIGNED_NODE_ID"]
FL -->|"stored in"| FLMap["graph.floatingLinks Map"]
FL -->|"registered in"| SideSet["linkStore unkeyed side set
(no unique target slot)"]
FL -.->|"may pass through"| Reroute
Reroute -.-|"floatingLinkIds Set"| FL
Reroute -.-|"floatingLinkIds (derived)"| FL
FL -->|"on drop"| Permanent["Permanent LLink
(registered in graph._links)"]
(graph._links + linkStore target index)"]
```
A floating link's slot attachment is fully encoded in its own endpoints —
slots hold no floating-link sets (`slotFloatingLinks()` in `LLink.ts` derives
attachment by scanning `graph.floatingLinks`).
## 3. Rendering
How LGraphCanvas draws each entity type.
@@ -254,7 +264,7 @@ stateDiagram-v2
```mermaid
stateDiagram-v2
[*] --> Created: node.connect() or connectSlots()
Created --> Registered: graph._links.set(id, link)
Created --> Registered: graph._addLink(link)
state Registered {
[*] --> Active
@@ -268,12 +278,18 @@ stateDiagram-v2
note right of Created
new LLink(id, type, origin, slot, target, slot)
_addLink sets graph._links entry and
registers topology in linkStore
(keyed by target input slot).
Output slot.links[] updated.
Input slot.link set.
end note
note right of Removed
Removed from graph._links.
Removed from graph._links and
unregistered from linkStore
(via _removeLink or link.disconnect).
Link geometry dropped from layoutStore.
Orphaned reroutes cleaned up.
graph._version incremented.
end note
@@ -362,6 +378,10 @@ graph TD
WVS["WidgetValueStore
(Pinia)"]
PES["PreviewExposureStore
(Pinia)"]
LKS["LinkStore
(Pinia)"]
RRS["RerouteStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
@@ -383,10 +403,20 @@ lastRerouteId, lastGroupId)"]
SGNode -->|"host-scoped preview exposures"| PES
PES -.->|"keyed by host node locator"| SGNode
%% LayoutMutations
%% LinkStore
Graph -->|"_addLink()/_removeLink() register/unregister"| LKS
Link <-->|"_state IS the store entry (endpoint accessors)"| LKS
LKS -.->|"keyed by rootGraphId + targetNodeId:targetSlot"| Link
%% RerouteStore
Graph -->|"_addReroute()/_removeReroute()"| RRS
Reroute <-->|"_chain (parentId, floating)"| RRS
RRS -.->|"derived linkIds membership"| Reroute
%% LayoutMutations (geometry only)
Node -->|"pos/size setter"| LM
Reroute -->|"move()"| LM
Link -->|"connectSlots()/disconnect()"| LM
Link -->|"disconnect(): drop link geometry"| LM
Graph -->|"add()/remove()"| LM
%% Graph state

View File

@@ -168,12 +168,24 @@ No central mechanism exists. It's easy to forget an increment (stale render) or
Domain objects call Pinia composables at the module level or in methods, creating implicit dependencies on the Vue runtime:
- `LLink.ts:24``const layoutMutations = useLayoutMutations()` (module scope)
- `Reroute.ts`same pattern at module scope
- `Reroute.ts:31``const layoutMutations = useLayoutMutations()` (module scope)
- `LLink.ts:7`imports the `layoutStore` singleton at module scope; `useLinkStore()` is called inside methods and helpers (needs an active Pinia, but not eagerly at import time)
- `Reroute.ts``useRerouteStore()` called inside methods for derived membership
- `BaseWidget.ts` — imports `useWidgetValueStore`
These make the domain objects untestable without a Vue app context.
### Reroute Membership Dual-Writes (solved)
`Reroute.linkIds` / `floatingLinkIds` were hand-maintained `Set`s written from
~10 scattered call sites (connect, disconnect, paste, configure, subgraph
pack/unpack, ...), with `Reroute.validateLinks()` repairing the inevitable
drift on load. Membership is now derived from the links' own `parentId`
chains via `rerouteStore` — the accessors are read-only, the write sites and
`validateLinks` are deleted, and orphaned reroutes are pruned by the derived
`totalLinks === 0` instead of set-repair. See
[reroute-chain-store.md](reroute-chain-store.md) (Decision 1).
### Change Notification Sprawl
`beforeChange()` and `afterChange()` (undo/redo checkpoints) are called from

View File

@@ -0,0 +1,112 @@
# Link Topology Store
Date: 2026-07-05 (retroactive design record; implemented in PR #13436)
Status: Accepted
Design record for extracting link topology into a dedicated store per
[ADR 0008](../adr/0008-entity-component-system.md). Amends the
`LinkEndpoints` component described there. The
[Reroute Chain Store](reroute-chain-store.md) builds directly on this
store; shared vocabulary lives in the
[Domain Glossary](domain-glossary.md).
## Decision 1: One state object, class reads through it
`LLink` no longer owns copies of its topology fields. A single plain
object,
```
LinkTopology { id, originNodeId, originSlot, targetNodeId, targetSlot,
type, parentId? }
```
backs the link: `LLink._state` holds it, and `id`, `type`, `origin_id`,
`origin_slot`, `target_id`, `target_slot`, and `parentId` are accessors
over it. Registration inserts that same object into the store by
reference and re-assigns `_state` to the reactive proxy read back from
the bucket, so subsequent class writes are Vue-tracked (the `BaseWidget`
pattern — see Decision 4 of the reroute chain store record). There is no
store-side copy to drift from the class: the store entry _is_ the
class's state.
The store is runtime state only; `LLink.asSerialisable` reads the same
fields it always did, and serialization goldens (key order plus
byte-identical round-trips) pin the wire format.
## Decision 2: Keyed by target input slot, not link id
The primary index is keyed by `` `${targetNodeId}:${targetSlot}` ``.
Two facts make this the right key:
- **The domain invariant**: at most one live link targets a given input
slot. The key is unique by construction for live links.
- **The dominant query**: consumers ask "is this input slot connected,
and by what?" (`isInputSlotConnected`, `getInputSlotLink`). The key
answers it in one lookup with no scan.
Link _ids_ are only unique per owning graph, not per root graph, so an
id-keyed root bucket needed a load-time link-id dedup pass and a
first-wins registration protocol to survive collisions across sibling
subgraph definitions. Re-keying by target slot deleted both: colliding
link ids never touch the index, so workflows load without link-id
rewrites.
Rejected: keeping the id key plus dedup/first-wins. That machinery
existed only to compensate for a key the queries never used.
## Decision 3: Root-graph-scoped buckets, unkeyed side set
Buckets are scoped by `rootGraph.id` — subgraphs share their root's
bucket — matching `widgetValueStore` and the later `rerouteStore`.
Re-keying entries to their owning graph was evaluated and rejected: it
reintroduces per-graph lifecycle bookkeeping the root scope avoids, and
no query wants owning-graph granularity that `graphTopologies` filtering
doesn't already provide.
Links without a unique live target go in a per-graph side `Set` instead
of the primary index:
- **Floating links** — exactly one assigned endpoint; the other is
`UNASSIGNED_NODE_ID`. A floating link attached to an input slot does
not answer `isInputSlotConnected`, preserving `input.link` semantics.
- **Links targeting `SUBGRAPH_OUTPUT_ID`** — the id is a shared
constant, so `targetNodeId:targetSlot` is not unique across the
subgraphs sharing a root bucket.
## Decision 4: Registration protocol
- `registerLink` is **first-wins**: if a different topology already
holds the target key, the call returns `undefined` and the loser
stays detached. `link._graphId` records a won registration; it is the
ownership marker that lets `unregisterLink` and re-registration no-op
safely for losers.
- `deleteLink` and `updateEndpoint` are **identity-checked** (`toRaw`
comparison): only the registered topology can vacate or re-key its
slot.
- `updateEndpoint` re-keys atomically — displace, patch fields through
a reactive wrapper, re-place under the new key — and returns
`undefined` when the new target is already occupied. The `reactive()`
wrap stays even though registered links already hold the proxy: the
store is public API and may be handed a raw topology object.
## Decision 5: Mutation chokepoints
All `graph._links` map mutation funnels through `LGraph._addLink` /
`_removeLink`, which pair the map write with store
registration/unregistration (and link-layout cleanup on removal).
`addFloatingLink` / `removeFloatingLink` do the same for the floating
map. `LLink.disconnect` performs the equivalent effects inline because
it only holds a `LinkNetwork`, and unregisters before reroute pruning so
derived reroute counts exclude the dying link. `clear()` and
subgraph-definition GC unregister whole graphs
(`unregisterAllLinkTopologies` / `clearGraph`).
## Scope
This design covers link topology (endpoints, type, chain terminus).
Link visual state (`color`, path caches) and the layout store's link
_geometry_ records are out of scope. The `input.link` / `output.links`
slot mirrors remain the litegraph-native representation un-migrated
consumers read; extracting them is the `SlotConnection` component work
in the [ECS migration plan](ecs-migration-plan.md), not part of this
store.

View File

@@ -6,20 +6,30 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Six dedicated stores extract entity state out of class instances into focused,
Eight dedicated stores extract entity state out of class instances into focused,
queryable registries, each owning one concern. Promoted value-widget topology is
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
`WidgetId`.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ---------------------------- | ----------------- | --------------------------------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link geometry, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
| LinkStore | `LLink` | Root graph | `` `${targetNodeId}:${targetSlot}` `` (target input slot) | Plain `LinkTopology` object |
| RerouteStore | `Reroute` | Root graph | `RerouteId` | Plain `RerouteChain` object |
**Update (2026-07-05):** `LinkStore` (`src/stores/linkStore.ts`, PR #13436) and
`RerouteStore` (`src/stores/rerouteStore.ts`, PR #13449) hold plain-data records
in reactive `Map` buckets — not Y.js — scoped by root graph (subgraphs share
their root's bucket). Floating links and links targeting subgraph outputs live
in a per-graph unkeyed side set. Design records:
[Link Topology Store](link-topology-store.md),
[Reroute Chain Store](reroute-chain-store.md).
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -149,28 +159,37 @@ The most architecturally advanced extraction — uses Y.js CRDTs for collaborati
```
ynodes: Y.Map<NodeLayoutMap> // nodeId → { pos, size, zIndex, bounds }
ylinks: Y.Map<Y.Map<...>> // linkId → link layout data
yreroutes: Y.Map<Y.Map<...>> // rerouteId → reroute layout data
yreroutes: Y.Map<Y.Map<...>> // rerouteId → { id, position }
```
**Update (2026-07-05):** The link-connectivity mirror (`ylinks`, `LinkData`,
`createLink`/`removeLink` mutations, `findLinksConnectedToNode`) was deleted
when link topology moved to `LinkStore` (PR #13436). LayoutStore now owns only
link/segment _geometry_ caches, and `RerouteData` carries `{ id, position }`
only — the write-only `parentId`/`linkIds` fields were removed.
### Write API
`useLayoutMutations()` (`src/renderer/core/layout/operations/layoutMutations.ts`) provides the mutation API:
- `moveNode(graphId, nodeId, pos)`
- `resizeNode(graphId, nodeId, size)`
- `setNodeZIndex(graphId, nodeId, zIndex)`
- `createLink(graphId, linkId, ...)`
- `removeLink(graphId, linkId)`
- `moveReroute(graphId, rerouteId, pos)`
- `moveNode(nodeId, pos)` / `batchMoveNodes(...)`
- `resizeNode(nodeId, size)`
- `setNodeZIndex(nodeId, zIndex)` / `bringNodeToFront(nodeId)`
- `createNode(nodeId, layout)` / `deleteNode(nodeId)`
- `createReroute(rerouteId, pos)` / `deleteReroute(rerouteId)` /
`moveReroute(rerouteId, pos, prevPos)`
(`createLink`/`removeLink` are gone — link topology is `LinkStore`'s concern.)
### The Scattered Access Problem
This composable is called at **module scope** in domain objects:
- `LLink.ts:24``const layoutMutations = useLayoutMutations()`
- `Reroute.ts` — same pattern
- `Reroute.ts:31` — `const layoutMutations = useLayoutMutations()`
- `LGraphNode.ts` — imported and called in methods
- `LLink.ts` no longer uses `useLayoutMutations` (its layout writes went away
with the `ylinks` mirror), but it still imports `layoutStore` and
`useLinkStore` at module scope
These module-scope calls create implicit dependencies on the Vue runtime and make the domain objects untestable without a full app context.
@@ -234,12 +253,14 @@ graph TD
Each store owns the identity scheme that fits its concern:
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ----------------------------------------------------------- | ------------------ | ----------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| LinkStore | `` `${targetNodeId}:${targetSlot}` `` (root-scoped buckets) | Composite string | No |
| RerouteStore | `RerouteId` (root-scoped buckets) | branded number | Yes (`RerouteId`) |
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
which carries its scope and survives renames at the store layer. The remaining
@@ -286,22 +307,26 @@ graph TD
subgraph Link["LLink"]
L_ext["Extracted:
- layout data → LayoutStore"]
- id, endpoints, type, parentId → LinkStore
(LLink._state IS the store entry;
fields are accessors over it)
- segment geometry → LayoutStore"]
L_rem["Remains on class:
- origin_id, target_id
- origin_slot, target_slot
- type, color, path
- color, path, _pos, _centreAngle
- data, _dragging
- disconnect(), resolve()"]
end
subgraph Reroute["Reroute"]
R_ext["Extracted:
- pos → LayoutStore"]
- pos → LayoutStore (partial mirror;
posInternal still truth)
- parentId, floating → RerouteStore
- linkIds, floatingLinkIds → derived
from links' parentId chains"]
R_rem["Remains on class:
- parentId, linkIds
- floatingLinkIds
- color, draw()
- posInternal (position truth)
- colour, draw()
- findSourceOutput()"]
end
@@ -346,15 +371,19 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | endpoints, type, parentId (LinkStore, via `_state` proxy); segment geometry (LayoutStore) | visual (color, path), drag state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Small — topology shipped (PR #13436); visual state and slot mirrors remain |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | parentId, floating (RerouteStore); pos (LayoutStore, partial mirror) | position truth (posInternal), visual, chain traversal | Position, RerouteChain ✅, RerouteVisual | Small — chain shipped (PR #13449); position ownership and visual remain |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
`RerouteChain` supersedes the earlier `RerouteLinks` component (ADR 0008
amendment, 2026-07-04): link membership is never stored — it is derived from
the links' `parentId` chains over `LinkStore`.
### Priority Order for Extraction
@@ -362,8 +391,11 @@ Based on existing progress and problem severity:
1. **Widget** — closest to done (value extraction complete, needs rendering/layout extraction)
2. **Node Position** — already in LayoutStore, needs branded ID and formal component type
3. **Link**small component set, high coupling pain
3. **Link** — ✅ topology shipped (LinkStore, PR #13436); slot mirrors
(`input.link`/`output.links`) and visual state remain
4. **Slot** — no extraction yet, but small and self-contained
5. **Reroute** — partially extracted, moderate complexity
(`SlotConnection` input side now answerable via LinkStore)
5. **Reroute** — ✅ chain shipped (RerouteStore, PR #13449); position
ownership and visual remain
6. **Group** — no extraction, but least coupled to other entities
7. **Subgraph** — not a separate entity kind; SubgraphStructure and SubgraphMeta become node components. Depends on Node and Link extraction first. See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md)

View File

@@ -0,0 +1,118 @@
# Reroute Chain Store
Date: 2026-07-04
Status: Accepted (design review; follow-up to the
[link topology store](link-topology-store.md), PR #13436)
Design record for extracting reroute connectivity state into a dedicated
store per [ADR 0008](../adr/0008-entity-component-system.md). Amends the
`RerouteLinks` component described there.
## Decision 1: The chain is the single source of truth for membership
"Which links pass through reroute R" is encoded twice today: each link's
`parentId` chain (`link.parentId` names the terminal reroute;
`reroute.parentId` walks upstream), and per-reroute `linkIds` /
`floatingLinkIds` Sets maintained by hand at roughly ten write sites
(`LGraphNode.connect`, `LLink.disconnect`, `LGraph.addFloatingLink` /
`removeFloatingLink` / `createReroute`, subgraph unpack,
`SubgraphInput/Output.connect`, `LinkConnector`), with
`Reroute.validateLinks` repairing drift at configure time.
The chain is primary. Membership becomes a derived reverse index:
```
linksThrough(R) = { L : R ∈ chain(L.parentId) }
```
computed over the link store's topologies plus the reroute chain states,
cached, and invalidated on chain mutation. The `Reroute` class exposes
`linkIds` / `floatingLinkIds` as derived accessors. The membership write
sites and `validateLinks` are deleted.
Rejected: storing membership Sets in the store (the `LLink._state`
pattern applied to whole membership). It keeps one _stored_ copy but
preserves the domain-level redundancy — chain and membership can still
disagree, and every dual write site survives.
## Decision 2: Store shape and keying
`rerouteStore` holds per-reroute chain state objects, registered by
reference (the class reads through them, mirroring `LLink._state`):
```
RerouteChain { id, parentId?, floating? }
```
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `RerouteId`,
matching `widgetValueStore` and `linkStore` scoping. Owning-graph buckets
were evaluated and rejected for the link store (2026-07-04) and are
rejected here for the same reasons.
`RerouteId` is the domain key because runtime allocation is already
per-root-unique: `Subgraph.state` delegates to the root graph's state, so
all graphs increment one shared `lastRerouteId` counter.
## Decision 3: Load-time reroute-id dedup
Serialized workflows from older frontends or external tools can carry
colliding reroute ids across sibling subgraph definitions. Today this is
tolerated only because `graph.reroutes` is a per-graph map; a root-scoped
bucket would break on it, and the layout store's bare-`rerouteId` keying
already collides latently.
On configure, colliding subgraph reroute ids are rewritten to fresh ids
from the shared counter, patching that subgraph's `link.parentId` and
`reroute.parentId` references — the same repair the node-id and link-id
dedup passes already perform. Rewritten ids serialize back changed.
Rejected: a first-wins registration protocol (losers detached via an
ownership flag). That is the apparatus the target-keyed link store
redesign existed to delete.
## Decision 4: Registration returns the reactive proxy
The derived membership index requires observable chain mutation.
`BaseWidget` already establishes the pattern: the store bucket is a
`reactive(Map)`, registration inserts the raw state and returns the
value read back from the map — the reactive proxy — and the class holds
that proxy as `_state` (`BaseWidget.setNodeId`,
`widgetValueStore.registerWidget`). Every subsequent class write goes
through the proxy and is tracked.
`rerouteStore.registerReroute` follows this: it returns the proxy and
the `Reroute` class reads and writes chain state through it, so
`reroute.parentId` mutations are tracked with no action chokepoint.
`LLink` previously deviated — `registerLinkTopology` left `link._state`
raw, which is why `linkStore.updateEndpoint` must re-wrap with
`reactive()` before patching, and why bare `link.parentId` writes were
invisible to effects. This migration aligns it with the `BaseWidget`
pattern: link registration re-assigns `_state` to the store proxy,
making `link.parentId` writes tracked. `updateEndpoint` keeps its
`reactive()` wrap as a guard — the store is public API and may be
handed a raw topology object.
## Decision 5: Serialization
`SerialisableReroute.linkIds` remains in the wire format, emitted from
the derived membership in ascending link-id order. All runtime producers
append links in ascending id order and the serialization goldens hold
ascending arrays, so chain-consistent workflows round-trip byte-identical.
Workflows whose stored `linkIds` contradict their chains are repaired on
the next save (membership re-derived, stale ids dropped, order
normalized). A reroute that no chain reaches at all is dropped at load —
where `validateLinks` used to preserve it if its stored ids named live
links — because the chain is primary. No compatibility shim for such
files. `floatingLinkIds` stays unserialized, rebuilt at runtime as
before.
## Scope
This design covers chain state, derived membership, and the `LLink`
proxy retrofit from Decision 4 (a small self-contained change, done
first). Reroute visual state (`_colour`, badge) is out of scope. The
`Reroute.pos` class field still mirrors the layout store's position;
that pre-existing duplication is a separate concern, not addressed
here.

View File

@@ -11,6 +11,8 @@ import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import type { WidgetId } from '@/types/widgetId'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
import { extractWidgetStringValue } from '@/composables/maskeditor/useMaskEditorLoader'
import { appendCloudResParam } from '@/platform/distribution/cloudPreviewUtil'
@@ -19,6 +21,8 @@ import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.v
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -29,9 +33,8 @@ import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
}
nodeData: ReturnType<typeof nodeToNodeData>
widgetIds: readonly WidgetId[]
action: { widget: IBaseWidget; node: LGraphNode }
}
@@ -43,6 +46,8 @@ const { mobile = false, builderMode = false } = defineProps<{
const { t } = useI18n()
const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const widgetValueStore = useWidgetValueStore()
const linkStore = useLinkStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
@@ -54,49 +59,61 @@ provide(WidgetHeightKey, mobile ? 'h-10' : 'h-7')
const resolvedInputs = useResolvedSelectedInputs()
const mappedSelections = computed((): WidgetEntry[] => {
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
function ensureSelectedWidgetState(
widgetId: WidgetId,
widget: IBaseWidget
): void {
if (widgetValueStore.getWidget(widgetId)) return
widgetValueStore.registerWidget(
widgetId,
{
type: widget.type,
value: widget.value,
options: widget.options,
label: widget.label,
serialize: widget.serialize,
disabled: widget.disabled
},
deriveWidgetRenderState(widget)
)
}
function isWidgetInputLinked(node: LGraphNode, widgetName: string): boolean {
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widgetName)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
}
const mappedSelections = computed((): WidgetEntry[] => {
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { widgetId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
}
const fullNodeData = nodeDataByNode.get(node)!
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.widgetId === widgetId
})
if (!matchingWidget) return []
matchingWidget.slotMetadata = undefined
matchingWidget.nodeId = node.id
ensureSelectedWidgetState(widgetId, widget)
const fullNodeData = nodeToNodeData(node, widgetId)
if (isWidgetInputLinked(node, widget.name)) return []
return [
{
key: widgetId,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
widgets: [matchingWidget]
},
nodeData: fullNodeData,
widgetIds: [widgetId],
action: { widget, node }
}
]
})
})
function getDropIndicator(node: LGraphNode) {
function getDropIndicator(node: LGraphNode, id: WidgetId) {
if (node.type !== 'LoadImage') return undefined
const stringValue = extractWidgetStringValue(node.widgets?.[0]?.value)
const stringValue = extractWidgetStringValue(
widgetValueStore.getWidget(id)?.value
)
const { filename, subfolder, type } = stringValue
? parseImageWidgetValue(stringValue)
@@ -120,8 +137,8 @@ function getDropIndicator(node: LGraphNode) {
}
}
function nodeToNodeData(node: LGraphNode) {
const dropIndicator = getDropIndicator(node)
function nodeToNodeData(node: LGraphNode, id: WidgetId) {
const dropIndicator = getDropIndicator(node, id)
const nodeData = extractVueNodeData(node)
return {
@@ -148,7 +165,13 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
persistedHeight,
nodeData,
widgetIds,
action
} in mappedSelections"
:key
:class="
cn(
@@ -235,6 +258,7 @@ defineExpose({ handleDragDrop })
>
<NodeWidgets
:node-data
:widget-ids
:class="
cn(
'gap-y-3 rounded-lg py-1 [&_textarea]:resize-y **:[.col-span-2]:grid-cols-1',

View File

@@ -13,8 +13,8 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -256,7 +256,10 @@ function clearWidgetErrors(
source.sourceWidgetName,
source.sourceWidgetName,
value,
options
{
min: source.sourceWidget.options?.min,
max: source.sourceWidget.options?.max
}
)
}

View File

@@ -2,14 +2,18 @@ import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLinkStore } from '@/stores/linkStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { widgetId } from '@/types/widgetId'
import WidgetItem from './WidgetItem.vue'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
const { mockGetInputSpecForWidget, StubWidgetComponent } = vi.hoisted(() => ({
@@ -204,5 +208,51 @@ describe('WidgetItem', () => {
expect(stub.value).toBe('model_a.safetensors')
})
it('passes null from widget state to the widget component', () => {
const id = widgetId('test-graph-id', toNodeId(1), 'ckpt_name')
const widget = createMockWidget({ widgetId: id, value: 'source value' })
useWidgetValueStore().registerWidget(id, {
type: 'combo',
value: null,
options: {}
})
const { container } = renderWidgetItem(widget)
const stub = getStubWidget(container)
expect(stub.value).toBe('null')
})
it('updates disabled options when the widget input is linked', async () => {
const inputs: INodeInputSlot[] = [
{
name: 'seed',
type: 'INT',
link: null,
boundingRect: [0, 0, 0, 0],
widget: { name: 'seed' }
}
]
const node = createMockNode(
fromAny<Partial<LGraphNode>, unknown>({ inputs })
)
const widget = createMockWidget({ name: 'seed', options: {} })
const { container } = renderWidgetItem(widget, node)
expect(getStubWidget(container).options.disabled).toBeUndefined()
useLinkStore().registerLink('test-graph-id', {
id: toLinkId(1),
originNodeId: toNodeId(2),
originSlot: 0,
targetNodeId: node.id,
targetSlot: 0,
type: 'INT'
})
await nextTick()
expect(getStubWidget(container).options.disabled).toBe(true)
})
})
})

View File

@@ -3,8 +3,6 @@ import { computed, customRef, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -15,13 +13,18 @@ import {
getComponent,
shouldExpand
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
SimplifiedWidget,
WidgetValue as SimplifiedWidgetValue
} from '@/types/simplifiedWidget'
import { widgetId } from '@/types/widgetId'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { cn } from '@comfyorg/tailwind-utils'
@@ -61,6 +64,7 @@ const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const widgetValueStore = useWidgetValueStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const linkStore = useLinkStore()
const isEditing = ref(false)
const widgetComponent = computed(() => {
@@ -69,16 +73,14 @@ const widgetComponent = computed(() => {
})
const isLinked = computed(() => {
const safeWidget = useVueNodeLifecycle()
.nodeManager.value?.vueNodeData.get(node.id)
?.widgets?.find((w) => w.name === widget.name)
return safeWidget?.slotMetadata
? !!safeWidget.slotMetadata.linked
: !!node.inputs?.find((inp) => inp.widget?.name === widget.name)?.link
const graphId = node.graph?.rootGraph.id
const slot = node.inputs?.findIndex((i) => i.widget?.name === widget.name)
if (!graphId || slot === undefined || slot < 0) return false
return linkStore.isInputSlotConnected(graphId, node.id, slot)
})
const simplifiedWidget = computed((): SimplifiedWidget => {
const graphId = node.graph?.rootGraph?.id
const graphId = node.graph?.rootGraph.id
const bareNodeId = stripGraphPrefix(node.id)
const widgetState = widget.widgetId
? useWidgetValueStore().getWidget(widget.widgetId)
@@ -93,7 +95,9 @@ const simplifiedWidget = computed((): SimplifiedWidget => {
return {
name: widgetName,
type: widgetType,
value: widgetState?.value ?? widget.value,
value: (widgetState
? widgetState.value
: widget.value) as SimplifiedWidgetValue,
label: widgetState?.label ?? widget.label,
options: { ...baseOptions, disabled },
spec: nodeDefStore.getInputSpecForWidget(node, widgetName),

View File

@@ -3,7 +3,10 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import {
extractVueNodeData,
useGraphNodeManager
} from '@/composables/graph/useGraphNodeManager'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetId } from '@/types/widgetId'
import {
@@ -62,6 +65,20 @@ describe('Node Reactivity', () => {
expect(onValueChange).toHaveBeenCalledTimes(1)
})
it('does not re-wrap node.widgets on repeated extraction', () => {
const { node } = createTestGraph()
const widgetsGetter = () =>
Object.getOwnPropertyDescriptor(node, 'widgets')?.get
extractVueNodeData(node)
const firstGetter = widgetsGetter()
extractVueNodeData(node)
extractVueNodeData(node)
expect(widgetsGetter()).toBe(firstGetter)
})
it('widget values remain reactive after a connection is made', async () => {
const { node, graph } = createTestGraph()
const store = useWidgetValueStore()
@@ -87,7 +104,7 @@ describe('Node Reactivity', () => {
})
})
describe('Widget slotMetadata reactivity on link disconnect', () => {
describe('Widget input link reactivity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
@@ -96,10 +113,8 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
// Add a widget and an associated input slot (simulates "widget converted to input")
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
graph.add(node)
@@ -112,31 +127,22 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
return { graph, node, upstream, linkId: link.id }
}
it('sets slotMetadata.linked to true when input has a link', () => {
it('exposes linked widget input slots through Vue node inputs', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('updates slotMetadata.linked to false after link disconnect event', async () => {
it('reprojects vueNodeData for the node when node:slot-links:changed fires for an input slot', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
const nodeDataBefore = vueNodeData.get(node.id)
// Verify initially linked
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Simulate link disconnection (as LiteGraph does before firing the event)
node.inputs[0].link = null
// Fire the trigger event that LiteGraph fires on disconnect
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
@@ -145,48 +151,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
linkId: 42
})
await nextTick()
// slotMetadata.linked should now be false
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('reactively updates disabled state in a derived computed after disconnect', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
// Mimic what processedWidgets does in NodeWidgets.vue:
// derive disabled from slotMetadata.linked
const derivedDisabled = computed(() => {
const widgets = nodeData.widgets ?? []
const widget = widgets.find((w) => w.name === 'prompt')
return widget?.slotMetadata?.linked ? true : false
})
// Initially linked → disabled
expect(derivedDisabled.value).toBe(true)
// Track changes
const onChange = vi.fn()
watch(derivedDisabled, onChange)
// Simulate disconnect
node.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
// The derived computed should now return false
expect(derivedDisabled.value).toBe(false)
expect(onChange).toHaveBeenCalledTimes(1)
expect(vueNodeData.get(node.id)).not.toBe(nodeDataBefore)
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
@@ -205,15 +170,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(node.id)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
})
it('names promoted widgets after the subgraph input slot and exposes the interior source name', () => {
// Subgraph input named "value" promotes an interior "prompt" widget. The
// projected widget's name is the input slot name "value"; the interior
// source widget name "prompt" is carried separately for backend lookups.
it('registers promoted widget render state separately from value state', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
@@ -229,23 +190,34 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
useGraphNodeManager(graph)
const widgetData = nodeData?.widgets?.find((w) => w.name === 'value')
expect(widgetData).toBeDefined()
expect(widgetData?.sourceWidgetName).toBe('prompt')
expect(widgetData?.slotMetadata).toBeDefined()
const id = widgetId(graph.id, subgraphNode.id, 'value')
const store = useWidgetValueStore()
const valueState = store.getWidget(id)
const renderState = store.getWidgetRenderState(id)
expect(valueState?.name).toBe('value')
expect(valueState?.value).toBe('hello')
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
})
it('clears stale slotMetadata when input no longer matches widget', async () => {
it('reflects input/widget renames after link refresh', async () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)!
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
expect(widgetData.slotMetadata?.linked).toBe(true)
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(true)
node.inputs[0].name = 'other'
node.inputs[0].widget = { name: 'other' }
@@ -261,7 +233,11 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
await nextTick()
expect(widgetData.slotMetadata).toBeUndefined()
expect(
nodeData.inputs?.some(
(slot) => slot.name === 'prompt' && slot.widget?.name === 'prompt'
)
).toBe(false)
})
})
@@ -368,15 +344,13 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNodeB.graph as LGraph
graph.add(subgraphNodeB)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNodeB.id)
const mappedWidget = nodeData?.widgets?.[0]
useGraphNodeManager(graph)
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.widgetId).toBe(
widgetId(graph.id, subgraphNodeB.id, 'b_input')
)
const id = widgetId(graph.id, subgraphNodeB.id, 'b_input')
const state = useWidgetValueStore().getWidget(id)
expect(state?.type).toBe('combo')
expect(subgraphNodeB.widgets[0]?.widgetId).toBe(id)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
@@ -405,27 +379,23 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const widgets = nodeData?.widgets
useGraphNodeManager(graph)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.widgetId).toBe(
widgetId(graph.id, subgraphNode.id, 'first_seed')
)
expect(widgets?.[1]?.widgetId).toBe(
const ids = subgraphNode.widgets.map((widget) => widget.widgetId)
expect(ids).toStrictEqual([
widgetId(graph.id, subgraphNode.id, 'first_seed'),
widgetId(graph.id, subgraphNode.id, 'second_seed')
)
expect(widgets?.[0]?.widgetId).not.toBe(widgets?.[1]?.widgetId)
])
expect(ids[0]).not.toBe(ids[1])
})
})
describe('Promoted widget sourceExecutionId', () => {
describe('Promoted widget render state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
it('registers plain render metadata for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
@@ -451,22 +421,21 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(subgraphNode.id)
const promotedWidget = nodeData?.widgets?.find(
(w) => w.name === 'ckpt_input'
useGraphNodeManager(graph)
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, subgraphNode.id, 'ckpt_input')
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.sourceWidgetName).toBe('ckpt_name')
// The interior node is inside subgraphNode (id=65),
// so its execution ID should be "65:<interiorNodeId>"
expect(promotedWidget?.sourceExecutionId).toBe(
`${subgraphNode.id}:${interiorNode.id}`
)
expect(renderState).toMatchObject({
hasLayoutSize: false,
isDOMWidget: false
})
expect(renderState).not.toHaveProperty('sourceWidgetName')
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
it('does not set sourceExecutionId for non-promoted widgets', () => {
it('registers plain render metadata for non-promoted widgets', () => {
const graph = new LGraph()
const node = new LGraphNode('test')
node.addWidget('number', 'steps', 20, () => undefined, {})
@@ -474,12 +443,14 @@ describe('Promoted widget sourceExecutionId', () => {
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(node.id)
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
useGraphNodeManager(graph)
expect(widget).toBeDefined()
expect(widget?.sourceExecutionId).toBeUndefined()
const renderState = useWidgetValueStore().getWidgetRenderState(
widgetId(graph.id, node.id, 'steps')
)
expect(renderState).toBeDefined()
expect(renderState).not.toHaveProperty('sourceExecutionId')
})
})

View File

@@ -1,14 +1,6 @@
/**
* Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations
*/
import { reactiveComputed } from '@vueuse/core'
import cloneDeep from 'es-toolkit/compat/cloneDeep'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type {
INodeInputSlot,
INodeOutputSlot
@@ -19,16 +11,6 @@ import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { isDOMWidget } from '@/scripts/domWidget'
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetIdForNode } from '@/utils/litegraphUtil'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import type { WidgetId } from '@/types/widgetId'
import type {
LGraph,
@@ -36,69 +18,14 @@ import type {
LGraphNode,
LGraphTriggerAction,
LGraphTriggerEvent,
LGraphTriggerParam,
SubgraphNode
LGraphTriggerParam
} from '@/lib/litegraph/src/litegraph'
import type { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import { app } from '@/scripts/app'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
type Badges = (LGraphBadge | (() => LGraphBadge))[]
/**
* Minimal render-specific widget data extracted from LiteGraph widgets.
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
widgetId?: WidgetId
nodeId?: NodeId
name: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
/** Control widget for seed randomization/increment/decrement */
controlWidget?: SafeControlWidget
/** Whether widget has custom layout size computation */
hasLayoutSize?: boolean
/** Whether widget is a DOM widget */
isDOMWidget?: boolean
/**
* Widget options needed for render decisions.
* Note: Most metadata should be accessed via widgetValueStore.getWidget().
*/
options?: {
canvasOnly?: boolean
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: unknown
}
/** Input specification from node definition */
spec?: InputSpec
/** Input slot metadata (index and link status) */
slotMetadata?: WidgetSlotMetadata
/**
* Execution ID of the interior node that owns the source widget.
* Only set for promoted widgets where the source node differs from the host
* subgraph node. Retained for source-scoped validation errors.
*/
sourceExecutionId?: NodeExecutionId
/**
* Interior source widget name. Only set for promoted widgets, where `name` is
* the host input slot name and the source widget name can differ.
*/
sourceWidgetName?: string
/** Tooltip text from the resolved widget. */
tooltip?: string
}
const reactiveArrayNodes = new WeakSet<LGraphNode>()
export interface VueNodeData {
executing: boolean
@@ -124,251 +51,23 @@ export interface VueNodeData {
showAdvanced?: boolean
subgraphId?: string | null
titleMode?: TitleMode
widgets?: SafeWidgetData[]
}
export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<NodeId, VueNodeData>
// Access to original LiteGraph nodes (non-reactive)
getNode(id: NodeId): LGraphNode | undefined
// Lifecycle methods
cleanup(): void
}
export function getControlWidget(
widget: IBaseWidget
): SafeControlWidget | undefined {
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
if (!cagWidget) return
return {
value: normalizeControlOption(cagWidget.value),
update: (value) => (cagWidget.value = normalizeControlOption(value))
function makeReactiveNodeArrays(node: LGraphNode): {
inputs: INodeInputSlot[]
outputs: INodeOutputSlot[]
} {
// Wrapping is one-shot: re-running would stack another getter layer per call.
if (reactiveArrayNodes.has(node)) {
return { inputs: node.inputs ?? [], outputs: node.outputs ?? [] }
}
}
interface SharedWidgetEnhancements {
controlWidget?: SafeControlWidget
spec?: InputSpec
}
function getSharedWidgetEnhancements(
node: LGraphNode,
widget: IBaseWidget
): SharedWidgetEnhancements {
const nodeDefStore = useNodeDefStore()
return {
controlWidget: getControlWidget(widget),
spec: nodeDefStore.getInputSpecForWidget(node, widget.name)
}
}
/**
* Validates that a value is a valid WidgetValue type
*/
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === null || value === undefined || value === void 0) {
return undefined
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') {
// Check if it's a File array
if (
Array.isArray(value) &&
value.length > 0 &&
value.every((item): item is File => item instanceof File)
) {
return value
}
// Otherwise it's a generic object
return value
}
// If none of the above, return undefined
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function extractWidgetDisplayOptions(
widget: IBaseWidget
): SafeWidgetData['options'] {
if (!widget.options) return undefined
return {
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only
}
}
function isDOMBackedWidget(widget: IBaseWidget): boolean {
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
interface PromotedWidgetMetadata {
controlWidget?: SafeControlWidget
isDOMWidget: boolean
sourceExecutionId?: NodeExecutionId
sourceWidgetName?: string
}
/**
* Resolves the interior source of a promoted subgraph input to derive the
* metadata that backend lookups key by (execution ID, interior widget name)
* plus the source widget's control + DOM nature. Also seeds host widget state
* if it is somehow missing. Returns undefined when the widget is not promoted.
*/
function resolvePromotedMetadata(
node: SubgraphNode,
widget: IBaseWidget
): PromotedWidgetMetadata | undefined {
const source = resolvePromotedWidgetSource(app.rootGraph, node, widget)
if (!source) return undefined
ensurePromotedHostWidgetState(
source.input.widgetId,
source.input,
source.sourceWidget
)
return {
controlWidget: getControlWidget(source.sourceWidget),
isDOMWidget: isDOMBackedWidget(source.sourceWidget),
sourceExecutionId: source.sourceExecutionId,
sourceWidgetName: source.sourceWidgetName
}
}
function safeWidgetMapper(
node: LGraphNode,
slotMetadata: Map<string, WidgetSlotMetadata>
): (widget: IBaseWidget) => SafeWidgetData {
const duplicateIndexByKey = new Map<string, number>()
return function (widget) {
try {
const duplicateKey = `${widget.name}:${widget.type}`
const duplicateIndex = duplicateIndexByKey.get(duplicateKey) ?? 0
duplicateIndexByKey.set(duplicateKey, duplicateIndex + 1)
const slotInfo = slotMetadata.get(widget.name)
// Wrapper callback specific to Nodes 2.0 rendering
const callback = (v: unknown) => {
const value = normalizeWidgetValue(v)
widget.value = value ?? undefined
// Match litegraph callback signature: (value, canvas, node, pos, event)
// Some extensions (e.g., Impact Pack) expect node as the 3rd parameter
widget.callback?.(value, app.canvas, node)
// Trigger redraw for all legacy widgets on this node (e.g., mask preview)
// This ensures widgets that depend on other widget values get updated
node.widgets?.forEach((w) => w.triggerDraw?.())
}
const promoted = node.isSubgraphNode()
? resolvePromotedMetadata(node, widget)
: undefined
return {
widgetId: getWidgetIdForNode(node, widget, duplicateIndex),
name: widget.name,
type: widget.type,
...getSharedWidgetEnhancements(node, widget),
...(promoted?.controlWidget && {
controlWidget: promoted.controlWidget
}),
callback,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: promoted?.isDOMWidget ?? isDOMWidget(widget),
options: extractWidgetDisplayOptions(widget),
slotMetadata: slotInfo,
sourceExecutionId: promoted?.sourceExecutionId,
sourceWidgetName: promoted?.sourceWidgetName,
tooltip: widget.tooltip
}
} catch (error) {
console.warn(
'[safeWidgetMapper] Failed to map widget:',
widget.name,
error
)
return {
name: widget.name || 'unknown',
type: widget.type || 'text'
}
}
}
}
function ensurePromotedHostWidgetState(
id: WidgetId,
input: INodeInputSlot,
sourceWidget: IBaseWidget | undefined
): void {
if (!sourceWidget) return
const store = useWidgetValueStore()
if (store.getWidget(id)) return
store.registerWidget(id, {
type: sourceWidget.type,
value: sourceWidget.value,
options: cloneDeep(sourceWidget.options ?? {}),
label: input.label ?? input.name,
serialize: sourceWidget.serialize,
disabled: sourceWidget.disabled
})
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined
): Map<string, WidgetSlotMetadata> {
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
let originNodeId: NodeId | undefined
let originOutputName: string | undefined
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
originNodeId = link.origin_id
originOutputName = originNode.outputs?.[link.origin_slot]?.name
}
}
const slotInfo: WidgetSlotMetadata = {
index,
linked: input.link != null,
originNodeId,
originOutputName,
type: String(input.type)
}
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
// Extract safe data from LiteGraph node for Vue consumption
export function extractVueNodeData(node: LGraphNode): VueNodeData {
// Determine subgraph ID - null for root graph, string for subgraphs
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
// Extract safe widget data
const slotMetadata = new Map<string, WidgetSlotMetadata>()
reactiveArrayNodes.add(node)
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
node,
@@ -376,8 +75,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
)
const reactiveWidgets = shallowReactive<IBaseWidget[]>(node.widgets ?? [])
if (existingWidgetsDescriptor?.get) {
// Node has a custom widgets getter (e.g. SubgraphNode's synthetic getter).
// Preserve it but sync results into a reactive array for Vue.
const originalGetter = existingWidgetsDescriptor.get
Object.defineProperty(node, 'widgets', {
get() {
@@ -406,6 +103,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
}
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
Object.defineProperty(node, 'inputs', {
get() {
@@ -417,6 +115,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
configurable: true,
enumerable: true
})
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
Object.defineProperty(node, 'outputs', {
get() {
@@ -429,19 +128,16 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
enumerable: true
})
const safeWidgets = reactiveComputed<SafeWidgetData[]>(() => {
const freshMetadata = buildSlotMetadata(node.inputs, node.graph)
slotMetadata.clear()
for (const [key, value] of freshMetadata) {
slotMetadata.set(key, value)
}
return { inputs: reactiveInputs, outputs: reactiveOutputs }
}
const widgets = node.isSubgraphNode()
? promotedInputWidgets(node)
: (node.widgets ?? [])
return widgets.map(safeWidgetMapper(node, slotMetadata))
})
export function extractVueNodeData(node: LGraphNode): VueNodeData {
const subgraphId =
node.graph && 'id' in node.graph && node.graph !== node.graph.rootGraph
? String(node.graph.id)
: null
const { inputs, outputs } = makeReactiveNodeArrays(node)
const nodeType =
node.type ||
node.constructor?.comfyClass ||
@@ -449,9 +145,6 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
node.constructor?.name ||
'Unknown'
const apiNode = node.constructor?.nodeData?.api_node ?? false
const badges = node.badges
return {
id: node.id,
title: typeof node.title === 'string' ? node.title : '',
@@ -459,14 +152,13 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
mode: node.mode || 0,
titleMode: node.title_mode,
selected: node.selected || false,
executing: false, // Will be updated separately based on execution state
executing: false,
subgraphId,
apiNode,
badges,
apiNode: node.constructor?.nodeData?.api_node ?? false,
badges: node.badges,
hasErrors: !!node.has_errors,
widgets: safeWidgets,
inputs: reactiveInputs,
outputs: reactiveOutputs,
inputs,
outputs,
flags: node.flags ? { ...node.flags } : undefined,
color: node.color || undefined,
bgcolor: node.bgcolor || undefined,
@@ -477,39 +169,26 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
}
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable
const { createNode, deleteNode, setSource } = useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<NodeId, VueNodeData>())
// Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<NodeId, LGraphNode>()
const refreshNodeSlots = (nodeId: NodeId) => {
const refreshNodeInputs = (nodeId: NodeId) => {
const nodeRef = nodeRefs.get(nodeId)
const currentData = vueNodeData.get(nodeId)
if (!nodeRef?.inputs || !currentData) return
if (!nodeRef || !currentData) return
const slotMetadata = buildSlotMetadata(nodeRef.inputs, graph)
// Update only widgets with new slot metadata, keeping other widget data intact
for (const widget of currentData.widgets ?? []) {
widget.slotMetadata = slotMetadata.get(widget.name)
}
nodeRef.inputs = [...nodeRef.inputs]
vueNodeData.set(nodeId, { ...currentData, inputs: nodeRef.inputs })
}
// Get access to original LiteGraph node (non-reactive)
const getNode = (id: NodeId): LGraphNode | undefined => {
return nodeRefs.get(id)
}
const getNode = (id: NodeId): LGraphNode | undefined => nodeRefs.get(id)
const syncWithGraph = () => {
if (!graph?._nodes) return
const currentNodes = new Set(graph._nodes.map((n) => n.id))
// Remove deleted nodes
for (const id of Array.from(vueNodeData.keys())) {
if (!currentNodes.has(id)) {
nodeRefs.delete(id)
@@ -517,76 +196,49 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
}
// Add/update existing nodes
graph._nodes.forEach((node) => {
const id = node.id
// Store non-reactive reference
nodeRefs.set(id, node)
// Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node))
})
}
/**
* Handles node addition to the graph - sets up Vue state and spatial indexing
* Defers position extraction until after potential configure() calls
*/
const handleNodeAdded = (
node: LGraphNode,
originalCallback?: (node: LGraphNode) => void
) => {
const id = node.id
// Store non-reactive reference to original node
nodeRefs.set(id, node)
// Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node))
const initializeVueNodeLayout = () => {
// Check if the node was removed mid-sequence
if (!nodeRefs.has(id)) return
// Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[1] }
// Skip layout creation if it already exists
// (e.g. in-place node replacement where the old node's layout is reused for the new node with the same ID).
const existingLayout = layoutStore.getNodeLayoutRef(id).value
if (existingLayout) return
// Add node to layout store with final positions
setSource(LayoutSource.Canvas)
void createNode(id, {
position: nodePosition,
size: nodeSize,
position: { x: node.pos[0], y: node.pos[1] },
size: { width: node.size[0], height: node.size[1] },
zIndex: node.order || 0,
visible: true
})
}
// Check if we're in the middle of configuring the graph (workflow loading)
if (window.app?.configuringGraph) {
// During workflow loading - defer layout initialization until configure completes
// Chain our callback with any existing onAfterGraphConfigured callback
node.onAfterGraphConfigured = useChainCallback(
node.onAfterGraphConfigured,
() => {
// Re-extract data now that configure() has populated title/slots/widgets/etc.
vueNodeData.set(id, extractVueNodeData(node))
initializeVueNodeLayout()
}
)
} else {
// Not during workflow loading - initialize layout immediately
// This handles individual node additions during normal operation
initializeVueNodeLayout()
}
// Call original callback if provided
if (originalCallback) {
void originalCallback(node)
}
@@ -603,16 +255,13 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
) => {
const id = node.id
// Remove node from layout store
setSource(LayoutSource.Canvas)
void deleteNode(id)
deleteNode(id)
dropNodeReferences(id)
for (const nodeId of nodeRefs.keys()) refreshNodeInputs(nodeId)
originalCallback?.(node)
}
/**
* Creates cleanup function for event listeners and state
*/
const createCleanupFunction = (
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
@@ -620,7 +269,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener: (e: CustomEvent<{ node: LGraphNode }>) => void
) => {
return () => {
// Restore original callbacks
graph.onNodeAdded = originalOnNodeAdded || undefined
graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined
@@ -630,19 +278,16 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
beforeNodeRemovedListener
)
// Clear all state maps
nodeRefs.clear()
vueNodeData.clear()
}
}
const setupEventListeners = (): (() => void) => {
// Store original callbacks
const originalOnNodeAdded = graph.onNodeAdded
const originalOnNodeRemoved = graph.onNodeRemoved
const originalOnTrigger = graph.onTrigger
// Set up graph event handlers
graph.onNodeAdded = (node: LGraphNode) => {
handleNodeAdded(node, originalOnNodeAdded)
}
@@ -761,11 +406,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
}
},
'node:slot-errors:changed': (slotErrorsEvent) => {
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
refreshNodeInputs(toNodeId(slotErrorsEvent.nodeId))
},
// NodeSlots.linkedWidgetedInputs and usePartitionedBadges read vueNodeData.inputs[].link and need reprojection on link change; remove once those migrate to useLinkStore
'node:slot-links:changed': (slotLinksEvent) => {
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
refreshNodeInputs(toNodeId(slotLinksEvent.nodeId))
}
},
'node:slot-label:changed': (slotLabelEvent) => {
@@ -773,16 +419,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
const nodeRef = nodeRefs.get(nodeId)
if (!nodeRef) return
// Force shallowReactive to detect the deep property change
// by re-assigning the affected array through the defineProperty setter.
if (slotLabelEvent.slotType !== NodeSlotType.OUTPUT && nodeRef.inputs) {
nodeRef.inputs = [...nodeRef.inputs]
}
if (slotLabelEvent.slotType !== NodeSlotType.INPUT && nodeRef.outputs) {
nodeRef.outputs = [...nodeRef.outputs]
}
// Re-extract widget data so the label reflects the rename
vueNodeData.set(nodeId, extractVueNodeData(nodeRef))
}
}
@@ -802,11 +444,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
break
}
// Chain to original handler
originalOnTrigger?.(event)
}
// Initialize state
syncWithGraph()
return createCleanupFunction(
@@ -817,10 +457,8 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
)
}
// Set up event listeners immediately
const cleanup = setupEventListeners()
// Process any existing nodes after event listeners are set up
if (graph._nodes && graph._nodes.length > 0) {
graph._nodes.forEach((node: LGraphNode) => {
if (graph.onNodeAdded) {

View File

@@ -10,7 +10,6 @@ import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMuta
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { app as comfyApp } from '@/scripts/app'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
function useVueNodeLifecycleIndividual() {
const canvasStore = useCanvasStore()
@@ -39,25 +38,7 @@ function useVueNodeLifecycleIndividual() {
// Seed reroutes into the Layout Store so hit-testing uses the new path
for (const reroute of activeGraph.reroutes.values()) {
const [x, y] = reroute.pos
const parent = reroute.parentId ?? undefined
const linkIds = Array.from(reroute.linkIds)
layoutMutations.createReroute(reroute.id, { x, y }, parent, linkIds)
}
// Seed existing links into the Layout Store (topology only)
for (const link of activeGraph._links.values()) {
if (
link.origin_id === UNASSIGNED_NODE_ID ||
link.target_id === UNASSIGNED_NODE_ID
)
continue
layoutMutations.createLink(
link.id,
link.origin_id,
link.origin_slot,
link.target_id,
link.target_slot
)
layoutMutations.createReroute(reroute.id, { x, y })
}
// Start sync AFTER seeding so bootstrap operations don't trigger

View File

@@ -145,6 +145,13 @@ function applySubgraphInputOrder(
})
reorderSubgraphInputs(subgraphNode, orderedIndices)
useWidgetValueStore().setNodeWidgetOrder(
subgraphNode.rootGraph.id,
subgraphNode.id,
subgraphNode.inputs.flatMap((input) =>
input.widgetId ? [input.widgetId] : []
)
)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
@@ -281,22 +288,26 @@ function seedNestedPromotedInputState(
)
if (!hostInput || hostInput.widgetId) return
const sourceState = useWidgetValueStore().getWidget(sourceSlot.widgetId)
const store = useWidgetValueStore()
const sourceState = store.getWidget(sourceSlot.widgetId)
if (!sourceState) return
const id = widgetId(subgraphNode.rootGraph.id, subgraphNode.id, inputName)
hostInput.widget ??= { name: inputName }
hostInput.widget.name = inputName
hostInput.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled,
isDOMWidget: sourceState.isDOMWidget
})
store.registerWidget(
id,
{
type: sourceState.type,
value: sourceState.value,
options: cloneDeep(sourceState.options ?? {}),
label: hostInput.label ?? sourceSlot.label ?? inputName,
serialize: sourceState.serialize,
disabled: sourceState.disabled
},
store.getWidgetRenderState(sourceSlot.widgetId) ?? {}
)
}
function promotePreviewViaExposure(

View File

@@ -11,7 +11,10 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LLink } from '@/lib/litegraph/src/LLink'
import { commonType } from '@/lib/litegraph/src/utils/type'
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
import {
getWidgetIds,
resolveNodeRootGraphId
} from '@/lib/litegraph/src/utils/widget'
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -48,6 +51,16 @@ type AutogrowNode = LGraphNode &
}
}
function syncNodeWidgetOrder(node: LGraphNode) {
const graphId = resolveNodeRootGraphId(node)
if (!graphId || !node.widgets) return
useWidgetValueStore().setNodeWidgetOrder(
graphId,
node.id,
getWidgetIds(node.widgets)
)
}
function ensureWidgetForInput(node: LGraphNode, input: INodeInputSlot) {
node.widgets ??= []
const { widget } = input
@@ -105,7 +118,10 @@ function dynamicComboWidget(
if (widget.widgetId) deleteWidget(widget.widgetId)
}
if (!newSpec) return
if (!newSpec) {
syncNodeWidgetOrder(node)
return
}
const insertionPoint = node.widgets.findIndex((w) => w === widget) + 1
const startingLength = node.widgets.length
@@ -140,6 +156,7 @@ function dynamicComboWidget(
node.inputs.findIndex((i) => i.name === widget.name) + 1
const addedWidgets = node.widgets.splice(startingLength)
node.widgets.splice(insertionPoint, 0, ...addedWidgets)
syncNodeWidgetOrder(node)
if (inputInsertionPoint === 0) {
if (
addedWidgets.length === 0 &&
@@ -541,8 +558,11 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
for (const input of toRemove) {
const widgetName = input?.widget?.name
if (!widgetName) continue
for (const widget of remove(node.widgets, (w) => w.name === widgetName))
for (const widget of remove(node.widgets, (w) => w.name === widgetName)) {
widget.onRemove?.()
if (widget.widgetId) useWidgetValueStore().deleteWidget(widget.widgetId)
}
syncNodeWidgetOrder(node)
}
node.size[1] = node.computeSize([...node.size])[1]
}

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraph } from '@/lib/litegraph/src/litegraph'
@@ -61,6 +63,8 @@ async function createNodeWithFilenamePrefix(
describe('Comfy.SaveImageExtraOutput', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
graph.add({
properties: { 'Node name for S&R': 'Sampler' },

View File

@@ -12,11 +12,17 @@ import {
Reroute,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type {
SerialisableGraph,
SerialisableLLink,
SerialisableReroute
} from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/utils/uuid'
import { zeroUuid } from '@/utils/uuid'
import { useLinkStore } from '@/stores/linkStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
@@ -39,6 +45,8 @@ import { nodeIdSpaceExhausted } from './__fixtures__/nodeIdSpaceExhausted'
import { uniqueSubgraphNodeIds } from './__fixtures__/uniqueSubgraphNodeIds'
import { test } from './__fixtures__/testExtensions'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function swapNodes(nodes: LGraphNode[]) {
const firstNode = nodes[0]
const lastNode = nodes[nodes.length - 1]
@@ -235,6 +243,25 @@ describe('Floating Links / Reroutes', () => {
expect(graph.reroutes.size).toBe(4)
})
test('slot floating links are derived from link endpoints', ({
expect,
linkedNodesGraph
}) => {
const graph = new LGraph(linkedNodesGraph)
graph.createReroute([0, 0], graph.links.values().next().value!)
const [origin, target] = graph.nodes
origin.disconnectOutput(0)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(1)
expect(slotFloatingLinks(graph, 'output', origin.id, 0)).toHaveLength(0)
const [floatingLink] = slotFloatingLinks(graph, 'input', target.id, 0)
graph.removeFloatingLink(floatingLink)
expect(slotFloatingLinks(graph, 'input', target.id, 0)).toHaveLength(0)
})
test('Floating reroutes should be removed when neither input nor output is connected', ({
expect,
floatingBranchGraph: graph
@@ -259,6 +286,91 @@ describe('Floating Links / Reroutes', () => {
})
})
describe('Link serialization goldens (ADR-0008 topology-store migration)', () => {
const LINK_KEYS = [
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
]
function expectContractKeyOrder(link: SerialisableLLink) {
const expectedKeys =
link.parentId === undefined ? LINK_KEYS : [...LINK_KEYS, 'parentId']
expect(Object.keys(link)).toEqual(expectedKeys)
}
test('plain links keep contract key order and round-trip byte-identically', ({
expect,
linkedNodesGraph
}) => {
const first = new LGraph(linkedNodesGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.links?.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('reroute-chain links keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const chainedLinks = (first.links ?? []).filter(
(link) => link.parentId !== undefined
)
expect(chainedLinks.length).toBeGreaterThan(0)
for (const link of first.links ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
test('floating links keep contract key order and round-trip byte-identically', ({
expect,
floatingLinkGraph
}) => {
const first = new LGraph(floatingLinkGraph).asSerialisable()
const second = new LGraph(first).asSerialisable()
expect(first.floatingLinks?.length).toBeGreaterThan(0)
for (const link of first.floatingLinks ?? []) expectContractKeyOrder(link)
expect(JSON.stringify(second.floatingLinks)).toBe(
JSON.stringify(first.floatingLinks)
)
})
const REROUTE_KEYS = ['id', 'parentId', 'pos', 'linkIds', 'floating'] as const
function expectRerouteContractKeyOrder(reroute: SerialisableReroute) {
const serialized: Record<string, unknown> = JSON.parse(
JSON.stringify(reroute)
)
const expectedKeys = REROUTE_KEYS.filter(
(key) => reroute[key] !== undefined
)
expect(Object.keys(serialized)).toEqual(expectedKeys)
}
test('reroutes keep contract key order and round-trip byte-identically', ({
expect,
reroutesComplexGraph
}) => {
const first = reroutesComplexGraph.asSerialisable()
const second = new LGraph(first).asSerialisable()
const reroutes = first.reroutes ?? []
expect(reroutes.length).toBeGreaterThan(0)
expect(reroutes.some((r) => r.floating !== undefined)).toBe(true)
expect(reroutes.some((r) => r.parentId === undefined)).toBe(true)
for (const reroute of reroutes) expectRerouteContractKeyOrder(reroute)
expect(JSON.stringify(second.reroutes)).toBe(JSON.stringify(first.reroutes))
})
})
describe('Graph Clearing and Callbacks', () => {
test('clear() calls both node.onRemoved() and graph.onNodeRemoved()', ({
expect
@@ -307,8 +419,6 @@ describe('Graph Clearing and Callbacks', () => {
})
test('clear() removes graph-scoped preview and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
@@ -437,10 +547,6 @@ describe('node:before-removed event', () => {
})
describe('Subgraph Definition Garbage Collection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function createSubgraphWithNodes(rootGraph: LGraph, nodeCount: number) {
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
@@ -830,7 +936,7 @@ describe('_removeDuplicateLinks', () => {
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
graph.state.lastLinkId = linkId
const dup = new LLink(linkId, 'number', source.id, 0, target.id, 0)
graph._links.set(dup.id, dup)
graph._addLink(dup)
source.outputs[0].links!.push(dup.id)
return dup
}
@@ -864,6 +970,20 @@ describe('_removeDuplicateLinks', () => {
expect(graph._links.has(dupLink.id)).toBe(false)
})
it('drops purged duplicates from the link store and keeps the survivor indexed', () => {
const { graph, source, target } = createConnectedGraph()
const keptLinkId = target.inputs[0].link!
const dup = injectDuplicateLink(graph, source, target)
graph._removeDuplicateLinks()
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(dup._graphId).toBeUndefined()
expect(store.getInputSlotLink(graphId, target.id, 0)?.id).toBe(keptLinkId)
})
it('keeps the valid link when input.link is at a shifted slot index', () => {
const { graph, source, target } = createConnectedGraph()
const validLinkId = target.inputs[0].link!
@@ -1099,7 +1219,6 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
const SHARED_NODE_IDS = [3, 8, 37]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
LiteGraph.registerNodeType('dummy', DummyNode)
})

View File

@@ -8,9 +8,12 @@ import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
import type { UUID } from '@/utils/uuid'
import { createUuidv4, zeroUuid } from '@/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { useLinkStore } from '@/stores/linkStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
@@ -29,10 +32,20 @@ import { LGraphCanvas } from './LGraphCanvas'
import { LGraphGroup } from './LGraphGroup'
import type { GroupId } from './LGraphGroup'
import { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import {
LLink,
registerLinkTopology,
unregisterAllLinkTopologies,
unregisterLinkTopology
} from './LLink'
import type { LinkId } from './LLink'
import { MapProxyHandler } from './MapProxyHandler'
import { Reroute } from './Reroute'
import {
registerRerouteChain,
Reroute,
unregisterAllRerouteChains,
unregisterRerouteChain
} from './Reroute'
import type { RerouteId } from './Reroute'
import { CustomEventTarget } from './infrastructure/CustomEventTarget'
import type { LGraphEventMap } from './infrastructure/LGraphEventMap'
@@ -59,6 +72,7 @@ import {
snapPoint
} from './measure'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { SubgraphInput } from './subgraph/SubgraphInput'
import { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphOutput } from './subgraph/SubgraphOutput'
@@ -92,6 +106,7 @@ import type {
import { getAllNestedItems } from './utils/collections'
import {
deduplicateSubgraphNodeIds,
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraph/subgraphDeduplication'
@@ -181,6 +196,55 @@ function fireNodeRemovalLifecycle(node: LGraphNode): void {
graph?.onNodeRemoved?.(node)
}
/** A reroute chain segment, terminal-first. */
interface ChainSegment {
/** Emitted reroute ids, in walk order. */
segment: RerouteId[]
/** `false` if the walk stopped at a broken reference or a cycle. */
complete: boolean
}
/**
* Resolves one hop of a reroute chain.
* @param id The reroute id to resolve.
* @returns The id to emit and the next id upstream, or `undefined` if the
* reference is broken.
*/
type ChainStep = (
id: RerouteId
) => { emit: RerouteId; next: RerouteId | undefined } | undefined
/**
* Walks a reroute chain, resolving each hop with `step`, until it runs out,
* hits a broken reference, or detects a cycle.
* @param start The reroute id to walk from, or `undefined` for an empty chain.
* @param step Resolves each hop of the chain.
* @returns The walked segment.
*/
function walkSegment(
start: RerouteId | undefined,
step: ChainStep
): ChainSegment {
const segment: RerouteId[] = []
const visited = new Set<RerouteId>()
let id = start
while (id !== undefined) {
if (visited.has(id)) {
console.error('Infinite parentId loop when unpacking')
return { segment, complete: false }
}
visited.add(id)
const hop = step(id)
if (!hop) {
console.error('Broken Id link when unpacking')
return { segment, complete: false }
}
segment.push(hop.emit)
id = hop.next
}
return { segment, complete: true }
}
/**
* LGraph is the class that contain a full graph. We instantiate one and add nodes to it, and then we can run the execution loop.
* supported callbacks:
@@ -395,6 +459,13 @@ export class LGraph
if (this.isRootGraph && graphId !== zeroUuid) {
usePreviewExposureStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
useLinkStore().clearGraph(graphId)
useRerouteStore().clearGraph(graphId)
} else {
// Subgraphs and unconfigured (zero-uuid) graphs share their store
// bucket with other graphs, so unregister each link individually.
unregisterAllLinkTopologies(this)
unregisterAllRerouteChains(this)
}
this.id = zeroUuid
@@ -1006,9 +1077,15 @@ export class LGraph
// Register all widgets with the WidgetValueStore now that node has a
// valid ID and graph reference.
if (node.widgets) {
const widgetValueStore = useWidgetValueStore()
for (const widget of node.widgets) {
if (isNodeBindable(widget)) widget.setNodeId(node.id)
}
widgetValueStore.setNodeWidgetOrder(
this.rootGraph.id,
node.id,
getWidgetIds(node.widgets)
)
}
this._nodes.push(node)
@@ -1112,6 +1189,8 @@ export class LGraph
if (!hasRemainingReferences) {
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
unregisterAllLinkTopologies(node.subgraph)
unregisterAllRerouteChains(node.subgraph)
this.rootGraph.subgraphs.delete(node.subgraph.id)
}
}
@@ -1422,49 +1501,48 @@ export class LGraph
link.id = toLinkId(++this._lastFloatingLinkId)
}
this.floatingLinksInternal.set(link.id, link)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks ??= new Set()
slot._floatingLinks.add(link)
} else {
console.warn(
`Adding invalid floating link: target/slot: [${link.target_id}/${link.target_slot}] origin/slot: [${link.origin_id}/${link.origin_slot}]`
)
}
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.add(link.id)
}
registerLinkTopology(this, link)
return link
}
removeFloatingLink(link: LLink): void {
this.floatingLinksInternal.delete(link.id)
const slot =
link.target_id !== UNASSIGNED_NODE_ID
? this.getNodeById(link.target_id)?.inputs?.[link.target_slot]
: this.getNodeById(link.origin_id)?.outputs?.[link.origin_slot]
if (slot) {
slot._floatingLinks?.delete(link)
}
unregisterLinkTopology(link)
const reroutes = LLink.getReroutes(this, link)
for (const reroute of reroutes) {
reroute.floatingLinkIds.delete(link.id)
if (reroute.floatingLinkIds.size === 0) {
delete reroute.floating
reroute.floating = undefined
}
if (reroute.totalLinks === 0) this.removeReroute(reroute.id)
}
}
/**
* Adds a link to this graph's {@link _links} map and registers its topology
* with the link store. The single entry point for populating {@link _links};
* routing every add through here keeps the store from silently desyncing.
*/
_addLink(link: LLink): void {
this._links.set(link.id, link)
registerLinkTopology(this, link)
}
/**
* Removes a link from this graph's {@link _links} map and unregisters it
* from the link and layout stores. The delete-side counterpart to
* {@link _addLink}; routing every removal through here keeps the stores
* from silently desyncing.
*/
_removeLink(linkId: LinkId): void {
const link = this._links.get(linkId)
if (!link) return
this._links.delete(linkId)
unregisterLinkTopology(link)
layoutStore.deleteLinkLayout(linkId)
}
/**
* Finds the link with the provided ID.
* @param id ID of link to find
@@ -1487,6 +1565,32 @@ export class LGraph
return id == null ? undefined : this.reroutes.get(id)
}
/**
* Adds a reroute to this graph's {@link reroutes} map and registers its
* chain state with the reroute store. The single entry point for
* populating {@link reroutes}; routing every add through here keeps the
* store from silently desyncing.
*/
_addReroute(reroute: Reroute): void {
this.reroutesInternal.set(reroute.id, reroute)
registerRerouteChain(this, reroute)
}
/**
* Removes a reroute from this graph's {@link reroutes} map and
* unregisters it from the reroute and layout stores. The delete-side
* counterpart to {@link _addReroute}.
*/
_removeReroute(id: RerouteId): void {
const reroute = this.reroutesInternal.get(id)
if (!reroute) return
this.reroutesInternal.delete(id)
unregisterRerouteChain(reroute)
const layoutMutations = useLayoutMutations()
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
}
/**
* Configures a reroute on the graph where ID is already known (probably deserialisation).
* Creates the object if it does not exist.
@@ -1496,7 +1600,6 @@ export class LGraph
id,
parentId,
pos,
linkIds,
floating
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
const rerouteId =
@@ -1508,11 +1611,11 @@ export class LGraph
}
const reroute = this.reroutes.get(rerouteId) ?? new Reroute(rerouteId, this)
const typedParentId =
reroute.parentId =
parentId === undefined ? undefined : toRerouteId(parentId)
const typedLinkIds = linkIds?.map(toLinkId)
reroute.update(typedParentId, pos, typedLinkIds, floating)
this.reroutes.set(rerouteId, reroute)
if (pos) reroute.pos = pos
reroute.floating = floating
this._addReroute(reroute)
return reroute
}
@@ -1530,41 +1633,24 @@ export class LGraph
}
const rerouteId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = rerouteId
const linkIds = before instanceof Reroute ? before.linkIds : [before.id]
const floatingLinkIds =
before instanceof Reroute ? before.floatingLinkIds : [before.id]
const reroute = new Reroute(
rerouteId,
this,
pos,
before.parentId,
linkIds,
floatingLinkIds
)
this.reroutes.set(rerouteId, reroute)
const chainLinks =
before instanceof Reroute
? [
...[...before.linkIds].map((id) => this._links.get(id)),
...[...before.floatingLinkIds].map((id) =>
this.floatingLinks.get(id)
)
]
: [before]
const reroute = new Reroute(rerouteId, this, pos, before.parentId)
this._addReroute(reroute)
// Register reroute in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createReroute(
rerouteId,
{ x: pos[0], y: pos[1] },
before.parentId,
Array.from(linkIds)
)
layoutMutations.createReroute(rerouteId, { x: pos[0], y: pos[1] })
for (const linkId of linkIds) {
const link = this._links.get(linkId)
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
const reroutes = LLink.getReroutes(this, link)
for (const x of reroutes.filter((x) => x.parentId === before.parentId)) {
x.parentId = rerouteId
}
}
for (const linkId of floatingLinkIds) {
const link = this.floatingLinks.get(linkId)
// Splice the new reroute into every chain that contained `before`
for (const link of chainLinks) {
if (!link) continue
if (link.parentId === before.parentId) link.parentId = rerouteId
@@ -1582,7 +1668,6 @@ export class LGraph
* @param id ID of reroute to remove
*/
removeReroute(id: RerouteId): void {
const layoutMutations = useLayoutMutations()
const { reroutes } = this
const reroute = reroutes.get(id)
if (!reroute) return
@@ -1625,11 +1710,7 @@ export class LGraph
}
}
reroutes.delete(id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(id)
this._removeReroute(id)
// This does not belong here; it should be handled by the caller, or run by a remove-many API.
// https://github.com/Comfy-Org/litegraph.js/issues/898
@@ -1667,9 +1748,7 @@ export class LGraph
const node = this.getNodeById(sampleLink.target_id)
const keepId = selectSurvivorLink(ids, node)
purgeOrphanedLinks(ids, keepId, this._links, (id) =>
this.getNodeById(toNodeId(id))
)
purgeOrphanedLinks(ids, keepId, this)
repairInputLinks(ids, keepId, node)
}
}
@@ -1908,7 +1987,7 @@ export class LGraph
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
link.origin_id = subgraphNode.id
link.origin_slot = i - 1
this.links.set(link.id, link)
this._addLink(link)
if (subgraphOutput instanceof SubgraphOutput) {
subgraphOutput.connect(
subgraphNode.findOutputSlotByType(link.type, true, true),
@@ -2042,30 +2121,6 @@ export class LGraph
group.pos[1] += offsetY
toSelect.push(group)
}
//cleanup reoute.linkIds now, but leave link.parentIds dangling
for (const islot of subgraphNode.inputs) {
if (!islot.link) continue
const link = this.links.get(islot.link)
if (!link) {
console.warn('Broken link', islot, islot.link)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
for (const oslot of subgraphNode.outputs) {
for (const linkId of oslot.links ?? []) {
const link = this.links.get(linkId)
if (!link) {
console.warn('Broken link', oslot, linkId)
continue
}
for (const reroute of LLink.getReroutes(this, link)) {
reroute.linkIds.delete(link.id)
}
}
}
const newLinks: {
oid: NodeId
oslot: number
@@ -2200,89 +2255,52 @@ export class LGraph
}
newLink.id = created.id
}
// Migrate the subgraph's reroutes to fresh ids at their new positions.
const rerouteIdMap = new Map<RerouteId, RerouteId>()
for (const reroute of subgraphNode.subgraph.reroutes.values()) {
if (
reroute.parentId !== undefined &&
rerouteIdMap.get(reroute.parentId) === undefined
) {
console.error('Missing Parent ID')
}
const migratedRerouteId = toRerouteId(
Number(this.state.lastRerouteId) + 1
)
this.state.lastRerouteId = migratedRerouteId
const migratedReroute = new Reroute(migratedRerouteId, this, [
const oldReroutes = subgraphNode.subgraph.reroutes
for (const reroute of oldReroutes.values()) {
const migratedId = toRerouteId(Number(this.state.lastRerouteId) + 1)
this.state.lastRerouteId = migratedId
const migratedReroute = new Reroute(migratedId, this, [
reroute.pos[0] + offsetX,
reroute.pos[1] + offsetY
])
rerouteIdMap.set(reroute.id, migratedReroute.id)
this.reroutes.set(migratedReroute.id, migratedReroute)
rerouteIdMap.set(reroute.id, migratedId)
this._addReroute(migratedReroute)
toSelect.push(migratedReroute)
}
//iterate over newly created links to update reroute parentIds
// Stitch each link's chain from its internal (migrated) and external
// segments, ordered by which side was nearest the input. External hops walk
// this graph's own reroutes; internal hops walk the old subgraph chain,
// emitting migrated ids.
for (const newLink of dedupedNewLinks) {
const linkInstance = this.links.get(newLink.id)
if (!linkInstance) {
continue
}
let instance: Reroute | LLink | undefined = linkInstance
let parentId: RerouteId | undefined
if (newLink.externalFirst) {
parentId = newLink.eparent
//TODO: recursion check/helper method? Probably exists, but wouldn't mesh with the reference tracking used by this implementation
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
}
if (!instance) continue
parentId = newLink.iparent
while (parentId) {
const migratedId = rerouteIdMap.get(parentId)
if (!migratedId) {
console.error('Broken Id link when unpacking')
break
}
instance.parentId = migratedId
instance = this.reroutes.get(migratedId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
const oldReroute = subgraphNode.subgraph.reroutes.get(parentId)
if (!oldReroute) {
console.error('Broken Id link when unpacking')
break
}
parentId = oldReroute.parentId
}
if (!instance) break
if (!newLink.externalFirst) {
parentId = newLink.eparent
while (parentId) {
instance.parentId = parentId
instance = this.reroutes.get(parentId)
if (!instance) {
console.error('Broken Id link when unpacking')
break
}
if (instance.linkIds.has(linkInstance.id))
throw new Error('Infinite parentId loop')
instance.linkIds.add(linkInstance.id)
parentId = instance.parentId
}
if (!linkInstance) continue
const internal = walkSegment(newLink.iparent, (id) => {
const emit = rerouteIdMap.get(id)
return emit === undefined
? undefined
: { emit, next: oldReroutes.get(id)?.parentId }
})
const external = walkSegment(newLink.eparent, (id) => {
const reroute = this.reroutes.get(id)
return reroute && { emit: id, next: reroute.parentId }
})
const [first, second] = newLink.externalFirst
? [external, internal]
: [internal, external]
const chain = first.complete
? [...first.segment, ...second.segment]
: first.segment
let segmentEnd: LLink | Reroute = linkInstance
for (const rerouteId of chain) {
segmentEnd.parentId = rerouteId
const next = this.reroutes.get(rerouteId)
if (!next) break
segmentEnd = next
}
}
@@ -2471,7 +2489,6 @@ export class LGraph
data: ISerialisedGraph | SerialisableGraph,
keep_old?: boolean
): boolean | undefined {
const layoutMutations = useLayoutMutations()
const options: LGraphEventMap['configuring'] = {
data,
clearGraph: !keep_old
@@ -2495,7 +2512,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.createFromArray(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
// #region `extra` embeds for v0.4
@@ -2536,7 +2553,7 @@ export class LGraph
if (Array.isArray(data.links)) {
for (const linkData of data.links) {
const link = LLink.create(linkData)
this._links.set(link.id, link)
this._addLink(link)
}
}
@@ -2590,6 +2607,20 @@ export class LGraph
)
: undefined
if (deduplicated) {
const reservedRerouteIds = new Set<number>()
for (const reroute of this.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
for (const sg of this.subgraphs.values())
for (const reroute of sg.reroutes.values())
reservedRerouteIds.add(Number(reroute.id))
deduplicateSubgraphRerouteIds(
deduplicated.subgraphs,
reservedRerouteIds,
this.state
)
}
const finalSubgraphs = deduplicated?.subgraphs ?? subgraphs
effectiveNodesData = deduplicated?.rootNodes ?? nodesData
@@ -2659,14 +2690,10 @@ export class LGraph
}
}
// Drop broken reroutes
// Drop reroutes that no live link or floating link passes through
for (const reroute of this.reroutes.values()) {
// Drop broken links, and ignore reroutes with no valid links
if (!reroute.validateLinks(this._links, this.floatingLinks)) {
this.reroutes.delete(reroute.id)
// Clean up layout store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
if (reroute.totalLinks === 0) {
this._removeReroute(reroute.id)
}
}

View File

@@ -256,6 +256,31 @@ describe('_deserializeItems paste-time migration & auto-expose', () => {
registeredTypesToCleanup.push(type)
}
it('prunes pasted reroutes that no pasted link passes through', () => {
const nodeType = 'test/clipboard-reroute-prune'
registerClipboardNodeType(nodeType)
const rootGraph = new LGraph()
const canvas = createCanvas(rootGraph)
const source = LiteGraph.createNode(nodeType)!
rootGraph.add(source)
const target = LiteGraph.createNode(nodeType)!
rootGraph.add(target)
const link = source.connect(0, target, 0)!
rootGraph.createReroute([50, 50], link)
// Copying only the reroute leaves it with no pasted link through it
const result = canvas._deserializeItems(
canvas._serializeItems([...rootGraph.reroutes.values()]),
{ position: [300, 300] }
)
expect(result?.reroutes.size).toBe(0)
expect(result?.created).toHaveLength(0)
expect(rootGraph.reroutes.size).toBe(1)
})
it('reconnects pasted inputs when clipboard node IDs differ from link endpoint types', () => {
const nodeType = 'test/clipboard-node-id-normalization'
registerClipboardNodeType(nodeType)

View File

@@ -93,7 +93,7 @@ describe('drawConnections widget-input slot positioning', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia())
setActivePinia(createTestingPinia({ stubActions: false }))
canvasElement = document.createElement('canvas')
canvasElement.width = 800

View File

@@ -26,7 +26,7 @@ import { LGraphNode } from './LGraphNode'
import type { NodeProperty } from './LGraphNode'
import { parseNodeId, serializeNodeId } from '@/types/nodeId'
import type { SerializedNodeId } from '@/types/nodeId'
import { LLink } from './LLink'
import { LLink, slotFloatingLinks } from './LLink'
import type { LinkId } from './LLink'
import { Reroute } from './Reroute'
import type { RerouteId } from './Reroute'
@@ -2784,13 +2784,8 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
output: INodeOutputSlot,
network: LinkNetwork
): boolean {
const outputLinks = [
...(output.links ?? []),
...[...(output._floatingLinks ?? new Set())]
]
return outputLinks.some(
(linkId) =>
typeof linkId === 'number' && network.getLink(linkId) !== undefined
return (output.links ?? []).some(
(linkId) => network.getLink(linkId) !== undefined
)
}
@@ -2801,7 +2796,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
if (isInRectangle(x, y, link_pos[0] - 15, link_pos[1] - 10, 30, 20)) {
// Drag multiple output links
if (e.shiftKey && hasRelevantOutputLinks(output, graph)) {
linkConnector.moveOutputLink(graph, output)
linkConnector.moveOutputLink(graph, node, output)
this._linkConnectorDrop()
return
}
@@ -2847,12 +2842,15 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
ctrlOrMeta &&
e.altKey &&
!e.shiftKey
if (input.link !== null || input._floatingLinks?.size) {
if (
input.link !== null ||
slotFloatingLinks(graph, 'input', node.id, i).length > 0
) {
// Existing link
if (shouldBreakLink || LiteGraph.click_do_break_link_to) {
node.disconnectInput(i, true)
} else if (e.shiftKey || this.allow_reconnect_links) {
linkConnector.moveInputLink(graph, input)
linkConnector.moveInputLink(graph, node, input)
}
}
@@ -4294,14 +4292,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
}
}
// Remap linkIds
for (const reroute of reroutes.values()) {
const ids = [...reroute.linkIds].map((x) => links.get(x)?.id ?? x)
reroute.update(reroute.parentId, undefined, ids, reroute.floating)
// Remove any invalid items
if (!reroute.validateLinks(graph.links, graph.floatingLinks)) {
// Remove reroutes that no pasted link passes through
for (const [sourceId, reroute] of reroutes) {
if (reroute.totalLinks === 0) {
graph.removeReroute(reroute.id)
reroutes.delete(sourceId)
const index = created.indexOf(reroute)
if (index !== -1) created.splice(index, 1)
}
}

View File

@@ -9,6 +9,7 @@ import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotC
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { toLinkId } from '@/types/linkId'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { adjustColor } from '@/utils/colorUtil'
@@ -30,7 +31,8 @@ import { BadgePosition, LGraphBadge } from './LGraphBadge'
import { LGraphButton } from './LGraphButton'
import type { LGraphButtonOptions } from './LGraphButton'
import { LGraphCanvas } from './LGraphCanvas'
import { LLink } from './LLink'
import { LLink, slotFloatingLinks } from './LLink'
import { anchorRerouteChain } from './Reroute'
import type { Reroute, RerouteId } from './Reroute'
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
import type { IDrawBoundingOptions } from './draw'
@@ -96,6 +98,7 @@ import type {
} from './types/widgets'
import { findFreeSlotOfType } from './utils/collections'
import { warnDeprecated } from './utils/feedback'
import { getWidgetIds } from './utils/widget'
import { distributeSpace } from './utils/spaceDistribution'
import { truncateText } from './utils/textUtils'
import { BaseWidget } from './widgets/BaseWidget'
@@ -1687,6 +1690,15 @@ export class LGraphNode
}
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.origin_id === this.id &&
floatingLink.origin_slot > slot
)
floatingLink.origin_slot--
}
}
this.onOutputRemoved?.(slot)
this.setDirtyCanvas(true, true)
@@ -1741,6 +1753,15 @@ export class LGraphNode
if (link) link.target_slot--
}
}
if (this.graph) {
for (const floatingLink of this.graph.floatingLinks.values()) {
if (
floatingLink.target_id === this.id &&
floatingLink.target_slot > slot
)
floatingLink.target_slot--
}
}
this.onInputRemoved?.(slot, slot_info[0])
this.setDirtyCanvas(true, true)
}
@@ -2055,6 +2076,20 @@ export class LGraphNode
widget.onRemove?.()
this.widgets.splice(widgetIndex, 1)
const graphId = this.graph?.rootGraph.id
if (graphId) {
const widgetValueStore = useWidgetValueStore()
// Drop the widget from the render order but keep its stored value, so a
// remove-then-re-add of the same widget id preserves what the user set.
if (widget.widgetId)
widgetValueStore.removeNodeWidgetOrder(widget.widgetId)
widgetValueStore.setNodeWidgetOrder(
graphId,
this.id,
getWidgetIds(this.widgets)
)
}
}
ensureWidgetRemoved(widget: IBaseWidget): void {
@@ -2891,8 +2926,6 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
const layoutMutations = useLayoutMutations()
const outputIndex = this.outputs.indexOf(output)
if (outputIndex === -1) {
console.warn('connectSlots: output not found')
@@ -2935,7 +2968,7 @@ export class LGraphNode
// if there is something already plugged there, disconnect
if (inputNode.inputs[inputIndex]?.link != null) {
graph.beforeChange()
inputNode.disconnectInput(inputIndex, true)
inputNode.disconnectInput(inputIndex, true, afterRerouteId)
}
const maybeCommonType =
@@ -2955,17 +2988,7 @@ export class LGraphNode
)
// add to graph links list
graph._links.set(link.id, link)
// Register link in Layout Store for spatial tracking
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.createLink(
link.id,
this.id,
outputIndex,
inputNode.id,
inputIndex
)
graph._addLink(link)
// connect in output
output.links ??= []
@@ -2983,24 +3006,7 @@ export class LGraphNode
})
}
// Reroutes
const reroutes = LLink.getReroutes(graph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) reroute.floating = undefined
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = graph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
graph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(graph, link)
graph.incrementVersion()
// link has been created now, so its updated
@@ -3075,7 +3081,6 @@ export class LGraphNode
if (!link)
throw new Error('[connectFloatingReroute] Floating link not found')
reroute.floatingLinkIds.add(link.id)
link.parentId = reroute.id
parentReroute.floating = undefined
return reroute
@@ -3106,11 +3111,14 @@ export class LGraphNode
const output = this.outputs[slot]
if (!output) return false
if (output._floatingLinks) {
for (const link of output._floatingLinks) {
if (link.hasOrigin(this.id, slot)) {
this.graph?.removeFloatingLink(link)
}
if (this.graph) {
for (const link of slotFloatingLinks(
this.graph,
'output',
this.id,
slot
)) {
this.graph.removeFloatingLink(link)
}
}
@@ -3235,9 +3243,15 @@ export class LGraphNode
* Disconnect one input
* @param slot Input slot index, or the name of the slot
* @param keepReroutes If `true`, reroutes will not be garbage collected.
* @param keepFloatingReroute Floating link(s) parented to this reroute are left
* intact, so a chain being reconnected is not pruned before its new link exists.
* @returns true if disconnected successfully or already disconnected, otherwise false
*/
disconnectInput(slot: number | string, keepReroutes?: boolean): boolean {
disconnectInput(
slot: number | string,
keepReroutes?: boolean,
keepFloatingReroute?: RerouteId
): boolean {
// Allow search by string
if (typeof slot === 'string') {
slot = this.findInputSlot(slot)
@@ -3262,11 +3276,11 @@ export class LGraphNode
const { graph } = this
if (!graph) throw new NullGraphError()
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
graph.removeFloatingLink(link)
}
// Break floating links, except the one whose reroute chain is being
// reconnected (its reroute would be pruned before the new link is added).
for (const link of slotFloatingLinks(graph, 'input', this.id, slot)) {
if (link.parentId === keepFloatingReroute) continue
graph.removeFloatingLink(link)
}
const link_id = this.inputs[slot].link

View File

@@ -0,0 +1,221 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LLink } from '@/lib/litegraph/src/litegraph'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { registerLinkTopology } from './LLink'
import {
createTestSubgraph,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
describe('LLink ↔ linkStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('connect registers, disconnect removes', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(true)
graph.removeLink(link.id)
expect(store.isInputSlotConnected(graph.rootGraph.id, b.id, 0)).toBe(false)
})
it('link.parentId writes are observable through the store query', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const parentId = computed(
() => store.getInputSlotLink(graph.rootGraph.id, b.id, 0)?.parentId
)
expect(parentId.value).toBeUndefined()
link.parentId = toRerouteId(7)
expect(parentId.value).toBe(7)
})
it('keeps writing to a disconnected link after it leaves the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
graph.removeLink(link.id)
expect(() => {
link.target_slot = 3
}).not.toThrow()
expect(link.target_slot).toBe(3)
})
it('keeps the winner registered when a colliding loser link disconnects', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const winner = a.connect(0, b, 0)!
const loser = new LLink(winner.id, 'INT', a.id, 0, b.id, 0)
registerLinkTopology(graph, loser)
loser.disconnect(graph)
const store = useLinkStore()
const graphId = graph.rootGraph.id
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(winner.id)
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(true)
})
it('unregisters a subgraph definitions links when its last instance is removed', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const subgraphNode = createTestSubgraphNode(subgraph)
rootGraph.add(subgraphNode)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
rootGraph.remove(subgraphNode)
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
})
it('keeps a subgraph definitions links registered while other instances remain', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const [first, second] = subgraph.nodes
const innerLink = first.connect(0, second, 0)!
const rootGraph = subgraph.rootGraph
const keptInstance = createTestSubgraphNode(subgraph)
const removedInstance = createTestSubgraphNode(subgraph, { id: 99 })
rootGraph.add(keptInstance)
rootGraph.add(removedInstance)
rootGraph.remove(removedInstance)
const store = useLinkStore()
expect(store.getInputSlotLink(rootGraph.id, second.id, 0)?.id).toBe(
innerLink.id
)
})
it('clearing a subgraph unregisters its links but keeps root links', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
const rootGraph = subgraph.rootGraph
const [first, second] = subgraph.nodes
first.connect(0, second, 0)
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', '*')
b.addInput('in', '*')
rootGraph.add(a)
rootGraph.add(b)
const rootLink = a.connect(0, b, 0)!
subgraph.clear()
const store = useLinkStore()
expect(store.isInputSlotConnected(rootGraph.id, second.id, 0)).toBe(false)
expect(store.getInputSlotLink(rootGraph.id, b.id, 0)?.id).toBe(rootLink.id)
})
it('clear() unregisters an unconfigured graphs links from the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const graphId = graph.rootGraph.id
const store = useLinkStore()
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(link.id)
graph.clear()
expect(store.isInputSlotConnected(graphId, b.id, 0)).toBe(false)
})
it('detaches a floating link from the store when it is removed', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
a.addOutput('out', '*')
graph.add(a)
const floating = new LLink(
toLinkId(7),
'*',
a.id,
0,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floating)
const graphId = graph.rootGraph.id
expect(floating._graphId).toBe(graphId)
graph.removeFloatingLink(floating)
expect(floating._graphId).toBeUndefined()
floating.origin_slot = 5
expect(floating.origin_slot).toBe(5)
})
it('moving a link via target_slot reindexes the store', () => {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in0', 'INT')
b.addInput('in1', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
const store = useLinkStore()
const nodeId = b.id
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(true)
link.target_slot = 1
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 0)).toBe(
false
)
expect(store.isInputSlotConnected(graph.rootGraph.id, nodeId, 1)).toBe(true)
})
})

View File

@@ -2,6 +2,7 @@ import { describe, expect } from 'vitest'
import { LLink } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { test } from './__fixtures__/testExtensions'
@@ -21,4 +22,17 @@ describe('LLink', () => {
expect(link.hasOrigin(4, 2)).toBe(true)
expect(link.hasTarget(5, 3)).toBe(true)
})
test('exposes topology fields backed by a single _state object', () => {
const link = new LLink(toLinkId(1), 'INT', 5, 0, 9, 2)
expect(link.origin_id).toBe(toNodeId(5))
link.target_slot = 4
expect(link._state.targetSlot).toBe(4)
expect(link.asSerialisable()).toMatchObject({
id: toLinkId(1),
origin_id: 5,
target_slot: 4,
type: 'INT'
})
})
})

View File

@@ -4,14 +4,18 @@ import {
} from '@/lib/litegraph/src/constants'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLinkStore } from '@/stores/linkStore'
import { toLinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID, toNodeId, serializeNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type { EndpointPatch } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import type { Reroute } from './Reroute'
@@ -27,8 +31,6 @@ import type {
} from './interfaces'
import type { Serialisable, SerialisableLLink } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { LinkId } from '@/types/linkId'
export type SerialisedLLinkArray = [
id: number,
@@ -93,22 +95,93 @@ type BasicReadonlyNetwork = Pick<
'getNodeById' | 'links' | 'getLink' | 'inputNode' | 'outputNode'
>
/** Routes an endpoint patch through {@link useLinkStore} if the link is registered, otherwise writes {@link LLink._state} directly. */
function applyEndpointPatch(link: LLink, patch: EndpointPatch): void {
if (link._graphId) {
const registered = useLinkStore().updateEndpoint(
link._graphId,
link._state,
patch
)
if (!registered) link._graphId = undefined
} else {
Object.assign(link._state, patch)
}
}
// this is the class in charge of storing link information
export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
static _drawDebug = false
/**
* The link's topology state. Once registered with {@link useLinkStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_state: LinkTopology
/** The graph this link is registered with in {@link useLinkStore}, if any. */
_graphId?: UUID
/** Link ID */
id: LinkId
parentId?: RerouteId
type: ISlotType
get id() {
return this._state.id
}
set id(value: LinkId) {
this._state.id = value
}
get type() {
return this._state.type
}
set type(value: ISlotType) {
this._state.type = value
}
/** Output node ID */
origin_id: NodeId
get origin_id() {
return this._state.originNodeId
}
set origin_id(value: NodeId) {
applyEndpointPatch(this, { originNodeId: value })
}
/** Output slot index */
origin_slot: number
get origin_slot() {
return this._state.originSlot
}
set origin_slot(value: number) {
applyEndpointPatch(this, { originSlot: value })
}
/** Input node ID */
target_id: NodeId
get target_id() {
return this._state.targetNodeId
}
set target_id(value: NodeId) {
applyEndpointPatch(this, { targetNodeId: value })
}
/** Input slot index */
target_slot: number
get target_slot() {
return this._state.targetSlot
}
set target_slot(value: number) {
applyEndpointPatch(this, { targetSlot: value })
}
get parentId() {
return this._state.parentId
}
set parentId(value: RerouteId | undefined) {
this._state.parentId = value
}
data?: number | string | boolean | { toToolTip?(): string }
_data?: unknown
@@ -165,13 +238,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
target_slot: number,
parentId?: RerouteId
) {
this.id = id
this.type = type
this.origin_id = toNodeId(origin_id)
this.origin_slot = origin_slot
this.target_id = toNodeId(target_id)
this.target_slot = target_slot
this.parentId = parentId
this._state = {
id,
type,
originNodeId: toNodeId(origin_id),
originSlot: origin_slot,
targetNodeId: toNodeId(target_id),
targetSlot: target_slot,
parentId
}
this._data = null
// center
@@ -462,19 +537,15 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
network.addFloatingLink(newLink)
}
network.links.delete(this.id)
unregisterLinkTopology(this)
layoutStore.deleteLinkLayout(this.id)
for (const reroute of reroutes) {
reroute.linkIds.delete(this.id)
if (!keepReroutes && !reroute.totalLinks) {
network.reroutes.delete(reroute.id)
// Delete reroute from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteReroute(reroute.id)
network._removeReroute(reroute.id)
}
}
network.links.delete(this.id)
// Delete link from Layout Store
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.deleteLink(this.id)
}
/**
@@ -505,3 +576,79 @@ export class LLink implements LinkSegment, Serialisable<SerialisableLLink> {
return copy
}
}
/**
* Finds the floating links attached to a slot. A floating link has exactly
* one assigned endpoint, so its attachment is fully encoded in its own
* origin/target fields; nothing is stored on the slot.
* @param network The network whose floating links to search
* @param side Which side of the slot's node the links attach to
* @param nodeId The node (or subgraph IO node id) owning the slot
* @param slot The slot index
*/
export function slotFloatingLinks(
network: Pick<ReadonlyLinkNetwork, 'floatingLinks'>,
side: 'input' | 'output',
nodeId: NodeId,
slot: number
): LLink[] {
const result: LLink[] = []
for (const link of network.floatingLinks.values()) {
const attached =
side === 'input'
? link.target_id === nodeId && link.target_slot === slot
: link.origin_id === nodeId && link.origin_slot === slot
if (attached) result.push(link)
}
return result
}
/**
* Registers a link's topology into {@link useLinkStore} and adopts the
* store's reactive proxy as {@link LLink._state}, so the store and the link
* always agree and field writes are tracked. Call this at every site that
* adds a link to a graph's link map (or floating link map).
*
* {@link LLink._graphId} is only set when the store keeps this link's state:
* a link that loses a first-wins id collision stays detached, so its writes
* and removal cannot corrupt the winner's registration.
* @param graph The graph (or subgraph) the link belongs to
* @param link The link to register
*/
export function registerLinkTopology(
graph: Pick<LGraph, 'rootGraph'>,
link: LLink
): void {
if (link.id === toLinkId(-1)) return // transient toFloating clone
const graphId = graph.rootGraph.id
const registered = useLinkStore().registerLink(graphId, link._state)
if (registered) {
link._state = registered
link._graphId = graphId
}
}
/**
* Removes a link's topology from {@link useLinkStore} and detaches the link.
* No-op for links that never won registration ({@link LLink._graphId} unset),
* so a first-wins collision loser cannot remove the winner's entry.
* @param link The link to unregister
*/
export function unregisterLinkTopology(link: LLink): void {
if (!link._graphId) return
useLinkStore().deleteLink(link._graphId, link._state)
link._graphId = undefined
}
/**
* Unregisters every link and floating link a graph owns. Used when a graph's
* links leave the store without a whole-bucket wipe: subgraph-definition
* removal, and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose links should be unregistered
*/
export function unregisterAllLinkTopologies(
graph: Pick<LGraph, 'links' | 'floatingLinks'>
): void {
for (const link of graph.links.values()) unregisterLinkTopology(link)
for (const link of graph.floatingLinks.values()) unregisterLinkTopology(link)
}

View File

@@ -0,0 +1,236 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useRerouteStore } from '@/stores/rerouteStore'
import { toRerouteId } from '@/types/rerouteId'
import { duplicateSubgraphNodeIds } from './__fixtures__/duplicateSubgraphNodeIds'
function connectedGraph() {
const graph = new LGraph()
const a = new LGraphNode('A')
const b = new LGraphNode('B')
a.addOutput('out', 'INT')
b.addInput('in', 'INT')
graph.add(a)
graph.add(b)
const link = a.connect(0, b, 0)!
return { graph, a, b, link }
}
describe('Reroute ↔ rerouteStore integration', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
it('createReroute registers the chain, removeReroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
graph.removeReroute(reroute.id)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('setReroute (deserialisation) registers the chain', () => {
const { graph } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(3),
parentId: undefined,
pos: [5, 5],
linkIds: []
})
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(3)
})
it('class parentId writes are observable through the store query', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
const parentId = computed(
() => store.getReroute(graph.rootGraph.id, first.id)?.parentId
)
expect(parentId.value).toBe(second.id)
first.parentId = undefined
expect(parentId.value).toBeUndefined()
})
it('disconnect pruning an empty reroute unregisters it', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: [link.id]
})
link.parentId = reroute.id
link.disconnect(graph)
expect(graph.reroutes.size).toBe(0)
expect(store.getReroute(graph.rootGraph.id, reroute.id)).toBeUndefined()
})
it('clear() removes the graphs chains from the store', () => {
const { graph, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
const graphId = graph.rootGraph.id
graph.clear()
expect(store.getReroute(graphId, reroute.id)).toBeUndefined()
})
it('deduplicates colliding subgraph reroute ids into one root bucket', () => {
LiteGraph.registerNodeType('dummy', LGraphNode)
const data = structuredClone(
duplicateSubgraphNodeIds
) as unknown as SerialisableGraph
const [a, b] = data.definitions!.subgraphs!
a.reroutes = [{ id: 1, pos: [0, 0], linkIds: [1] }]
a.links![0].parentId = toRerouteId(1)
b.reroutes = [{ id: 1, pos: [0, 0], linkIds: [2] }]
b.links![0].parentId = toRerouteId(1)
const graph = new LGraph(data)
const store = useRerouteStore()
const subgraphs = [...graph.subgraphs.values()]
const rerouteIds = subgraphs.map((sg) => [...sg.reroutes.keys()][0])
expect(new Set(rerouteIds).size).toBe(2)
for (const sg of subgraphs) {
const [reroute] = [...sg.reroutes.values()]
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.id).toBe(
reroute.id
)
const [link] = [...sg._links.values()]
expect(link.parentId).toBe(reroute.id)
}
})
it('linkIds follows the chain without manual set maintenance', () => {
const { graph, link } = connectedGraph()
const reroute = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: []
})
link.parentId = reroute.id
expect([...reroute.linkIds]).toEqual([link.id])
link.parentId = undefined
expect(reroute.linkIds.size).toBe(0)
})
it('parentId setter rejects a mutual-parent cycle', () => {
const { graph } = connectedGraph()
const first = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [10, 10],
linkIds: []
})
const second = graph.setReroute({
id: toRerouteId(2),
parentId: undefined,
pos: [20, 20],
linkIds: []
})
first.parentId = second.id
second.parentId = first.id
expect(second.parentId).toBeUndefined()
expect(first.getReroutes()).not.toBeNull()
})
it('parentId setter rejects extending a chain back onto its root', () => {
const { graph } = connectedGraph()
const a = graph.setReroute({
id: toRerouteId(1),
parentId: undefined,
pos: [0, 0],
linkIds: []
})
const b = graph.setReroute({
id: toRerouteId(2),
parentId: a.id,
pos: [0, 0],
linkIds: []
})
const c = graph.setReroute({
id: toRerouteId(3),
parentId: b.id,
pos: [0, 0],
linkIds: []
})
a.parentId = c.id
expect(a.parentId).toBeUndefined()
expect(c.getReroutes()).not.toBeNull()
})
it('snapToGrid mirrors the snapped position into the layout store', () => {
const { graph, link } = connectedGraph()
const reroute = graph.createReroute([12, 17], link)!
reroute.snapToGrid(10)
expect(layoutStore.getRerouteLayout(reroute.id)?.position).toEqual({
x: reroute.pos[0],
y: reroute.pos[1]
})
})
it('refuses parentId writes that would create a cycle, allows repair', () => {
const { graph, link } = connectedGraph()
const first = graph.createReroute([10, 10], link)!
const second = graph.createReroute([20, 20], first)!
expect(first.parentId).toBe(second.id)
second.parentId = first.id
expect(second.parentId).toBeUndefined()
second._chain.parentId = first.id
second.parentId = undefined
expect(second.parentId).toBeUndefined()
})
it('floating marker survives through the store state', () => {
const { graph, a, link } = connectedGraph()
const store = useRerouteStore()
const reroute = graph.createReroute([10, 10], link)!
a.disconnectOutput(0)
expect(reroute.floating).toEqual({ slotType: 'input' })
expect(store.getReroute(graph.rootGraph.id, reroute.id)?.floating).toEqual({
slotType: 'input'
})
})
})

View File

@@ -1,10 +1,15 @@
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { EMPTY_MEMBERSHIP, useRerouteStore } from '@/stores/rerouteStore'
import type { RerouteMembership } from '@/stores/rerouteStore'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type { FloatingRerouteSlot, RerouteChain } from '@/types/rerouteChain'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import { LayoutSource } from '@/renderer/core/layout/types'
import { LGraphBadge } from './LGraphBadge'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import { LLink } from './LLink'
import type { LinkId } from './LLink'
@@ -25,14 +30,9 @@ import type { Serialisable, SerialisableReroute } from './types/serialisation'
const layoutMutations = useLayoutMutations()
export type { FloatingRerouteSlot } from '@/types/rerouteChain'
export type { RerouteId } from '@/types/rerouteId'
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
/** Floating connection to an input or output */
slotType: 'input' | 'output'
}
/**
* Represents an additional point on the graph that a link path will travel through. Used for visual organisation only.
*
@@ -59,24 +59,52 @@ export class Reroute
/** The network this reroute belongs to. Contains all valid links and reroutes. */
private readonly network: WeakRef<LinkNetwork>
private parentIdInternal?: RerouteId
/**
* The reroute's chain state. Once registered with {@link useRerouteStore},
* this is the store's reactive proxy, so field writes are tracked.
*/
_chain: RerouteChain
/** The graph this reroute is registered with in {@link useRerouteStore}, if any. */
_graphId?: UUID
public get parentId(): RerouteId | undefined {
return this.parentIdInternal
return this._chain.parentId
}
/** Ignores attempts to create an infinite loop. @inheritdoc */
public set parentId(value) {
if (value === this.id) return
if (this.getReroutes() === null) return
this.parentIdInternal = value
if (value !== undefined && this.createsParentCycle(value)) return
this._chain.parentId = value
}
/** Walks the prospective parent chain from `value`, reporting whether it loops back to this reroute. */
private createsParentCycle(value: RerouteId): boolean {
const network = this.network.deref()
const visited = new Set<RerouteId>([this.id])
let nextId: RerouteId | undefined = value
while (nextId !== undefined) {
if (visited.has(nextId)) return true
visited.add(nextId)
nextId = network?.reroutes.get(nextId)?.parentId
}
return false
}
public get parent(): Reroute | undefined {
return this.network.deref()?.getReroute(this.parentIdInternal)
return this.network.deref()?.getReroute(this._chain.parentId)
}
/** This property is only defined on the last reroute of a floating reroute chain (closest to input end). */
floating?: FloatingRerouteSlot
get floating(): FloatingRerouteSlot | undefined {
return this._chain.floating
}
set floating(value: FloatingRerouteSlot | undefined) {
this._chain.floating = value
}
private readonly posInternal: Point = [0, 0]
/** @inheritdoc */
@@ -120,11 +148,24 @@ export class Reroute
/** @inheritdoc */
selected?: boolean
/** The ID ({@link LLink.id}) of every link using this reroute */
linkIds: Set<LinkId>
private get membership(): RerouteMembership {
return this._graphId
? useRerouteStore().getMembership(this._graphId, this.id)
: EMPTY_MEMBERSHIP
}
/**
* The ID ({@link LLink.id}) of every link using this reroute.
* Derived from the links' parentId chains; never stored.
*/
get linkIds(): ReadonlySet<LinkId> {
return this.membership.linkIds
}
/** The ID ({@link LLink.id}) of every floating link using this reroute */
floatingLinkIds: Set<LinkId>
get floatingLinkIds(): ReadonlySet<LinkId> {
return this.membership.floatingLinkIds
}
/** Cached cos */
cos: number = 0
@@ -201,61 +242,18 @@ export class Reroute
* @param id Unique identifier for this reroute
* @param network The network of links this reroute belongs to. Internally converted to a WeakRef.
* @param pos Position in graph coordinates
* @param linkIds Link IDs ({@link LLink.id}) of all links that use this reroute
*/
constructor(
id: RerouteId,
network: LinkNetwork,
pos?: Point,
parentId?: RerouteId,
linkIds?: Iterable<LinkId>,
floatingLinkIds?: Iterable<LinkId>
parentId?: RerouteId
) {
this.id = id
this.network = new WeakRef(network)
this._chain = { id }
this.parentId = parentId
if (pos) this.pos = pos
this.linkIds = new Set(linkIds)
this.floatingLinkIds = new Set(floatingLinkIds)
}
/**
* Applies a new parentId to the reroute, and optinoally a new position and linkId.
* Primarily used for deserialisation.
* @param parentId The ID of the reroute prior to this reroute, or
* `undefined` if it is the first reroute connected to a nodes output
* @param pos The position of this reroute
* @param linkIds All link IDs that pass through this reroute
*/
update(
parentId: RerouteId | undefined,
pos?: Point,
linkIds?: Iterable<LinkId>,
floating?: FloatingRerouteSlot
): void {
this.parentId = parentId
if (pos) this.pos = pos
if (linkIds) this.linkIds = new Set(linkIds)
this.floating = floating
}
/**
* Validates the linkIds this reroute has. Removes broken links.
* @param links Collection of valid links
* @returns true if any links remain after validation
*/
validateLinks(
links: ReadonlyMap<LinkId, LLink>,
floatingLinks: ReadonlyMap<LinkId, LLink>
): boolean {
const { linkIds, floatingLinkIds } = this
for (const linkId of linkIds) {
if (!links.has(linkId)) linkIds.delete(linkId)
}
for (const linkId of floatingLinkIds) {
if (!floatingLinks.has(linkId)) floatingLinkIds.delete(linkId)
}
return linkIds.size > 0 || floatingLinkIds.size > 0
}
/**
@@ -268,15 +266,15 @@ export class Reroute
*/
getReroutes(visited = new Set<Reroute>()): Reroute[] | null {
// No parentId - last in the chain
if (this.parentIdInternal === undefined) return [this]
if (this._chain.parentId === undefined) return [this]
// Invalid chain - looped
if (visited.has(this)) return null
visited.add(this)
const parent = this.network.deref()?.reroutes.get(this.parentIdInternal)
const parent = this.network.deref()?.reroutes.get(this._chain.parentId)
// Invalid parent (or network) - drop silently to recover
if (!parent) {
this.parentIdInternal = undefined
this._chain.parentId = undefined
return [this]
}
@@ -295,14 +293,14 @@ export class Reroute
withParentId: RerouteId,
visited = new Set<Reroute>()
): Reroute | null | undefined {
if (this.parentIdInternal === withParentId) return this
if (this._chain.parentId === withParentId) return this
if (visited.has(this)) return null
visited.add(this)
if (this.parentIdInternal === undefined) return
if (this._chain.parentId === undefined) return
return this.network
.deref()
?.reroutes.get(this.parentIdInternal)
?.reroutes.get(this._chain.parentId)
?.findNextReroute(withParentId, visited)
}
@@ -386,31 +384,14 @@ export class Reroute
/**
* Changes the origin node/output of all floating links that pass through this reroute.
* @param node The new origin node
* @param output The new origin output slot
* @param index The slot index of {@link output}
* @param index The slot index of the new origin output
*/
setFloatingLinkOrigin(
node: LGraphNode,
output: INodeOutputSlot,
index: number
) {
const network = this.network.deref()
setFloatingLinkOrigin(node: LGraphNode, index: number) {
const floatingOutLinks = this.getFloatingLinks('output')
if (!floatingOutLinks)
throw new Error('[setFloatingLinkOrigin]: Invalid network.')
if (!floatingOutLinks.length) return
output._floatingLinks ??= new Set()
for (const link of floatingOutLinks) {
// Update cached floating links
output._floatingLinks.add(link)
network
?.getNodeById(link.origin_id)
?.outputs[link.origin_slot]?._floatingLinks?.delete(link)
// Update the floating link
link.origin_id = node.id
link.origin_slot = index
}
@@ -437,47 +418,22 @@ export class Reroute
const offsetY = LiteGraph.NODE_SLOT_HEIGHT * 0.7
const { pos } = this
const previousPos = { x: pos[0], y: pos[1] }
pos[0] = snapTo * Math.round(pos[0] / snapTo)
pos[1] = snapTo * Math.round((pos[1] - offsetY) / snapTo) + offsetY
layoutMutations.setSource(LayoutSource.Canvas)
layoutMutations.moveReroute(this.id, { x: pos[0], y: pos[1] }, previousPos)
return true
}
removeAllFloatingLinks() {
for (const linkId of this.floatingLinkIds) {
this.removeFloatingLink(linkId)
}
}
removeFloatingLink(linkId: LinkId) {
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(linkId)
if (!floatingLink) {
console.warn(
`[Reroute.removeFloatingLink] Floating link not found: ${linkId}, ignoring and discarding ID.`
)
this.floatingLinkIds.delete(linkId)
return
}
network.removeFloatingLink(floatingLink)
}
/**
* Removes a link or floating link from this reroute, by matching link object instance equality.
* @param link The link to remove.
* @remarks Does not remove the link from the network.
*/
removeLink(link: LLink) {
const network = this.network.deref()
if (!network) return
const floatingLink = network.floatingLinks.get(link.id)
if (link === floatingLink) {
this.floatingLinkIds.delete(link.id)
} else {
this.linkIds.delete(link.id)
for (const linkId of [...this.floatingLinkIds]) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink) network.removeFloatingLink(floatingLink)
}
}
@@ -688,7 +644,7 @@ export class Reroute
id,
parentId,
pos: [pos[0], pos[1]],
linkIds: [...linkIds],
linkIds: [...linkIds].sort((a, b) => a - b),
floating: this.floating ? { slotType: this.floating.slotType } : undefined
}
}
@@ -822,3 +778,68 @@ function getNextPos(
function getDirection(fromPos: Point, toPos: Point) {
return Math.atan2(toPos[1] - fromPos[1], toPos[0] - fromPos[0])
}
/**
* Marks a link's reroute chain as no longer floating: clears each reroute's
* floating marker and drag state, and removes any floating link that
* terminates at the chain's last reroute. Call when a real link connects
* through the chain.
* @param network The network containing the chain
* @param link The link whose chain was just connected
*/
export function anchorRerouteChain(network: LinkNetwork, link: LLink): void {
const reroutes = LLink.getReroutes(network, link)
for (const reroute of reroutes) {
reroute.floating = undefined
reroute._dragging = undefined
}
const lastReroute = reroutes.at(-1)
if (!lastReroute) return
for (const linkId of lastReroute.floatingLinkIds) {
const floatingLink = network.floatingLinks.get(linkId)
if (floatingLink?.parentId === lastReroute.id) {
network.removeFloatingLink(floatingLink)
}
}
}
/**
* Registers a reroute's chain state into {@link useRerouteStore} and adopts
* the store's reactive proxy as {@link Reroute._chain}, so the store and the
* reroute always agree and field writes are tracked. Call this at every
* site that adds a reroute to a graph's reroute map.
* @param graph The graph (or subgraph) the reroute belongs to
* @param reroute The reroute to register
*/
export function registerRerouteChain(
graph: Pick<LGraph, 'rootGraph'>,
reroute: Reroute
): void {
const graphId = graph.rootGraph.id
reroute._chain = useRerouteStore().registerReroute(graphId, reroute._chain)
reroute._graphId = graphId
}
/**
* Removes a reroute's chain state from {@link useRerouteStore} and detaches
* the reroute. No-op for reroutes that were never registered.
* @param reroute The reroute to unregister
*/
export function unregisterRerouteChain(reroute: Reroute): void {
if (!reroute._graphId) return
useRerouteStore().deleteReroute(reroute._graphId, reroute._chain)
reroute._graphId = undefined
}
/**
* Unregisters every reroute a graph owns. Used when a graph's reroutes
* leave the store without a whole-bucket wipe: subgraph-definition removal,
* and clearing a graph that shares its bucket with other graphs.
* @param graph The graph whose reroutes should be unregistered
*/
export function unregisterAllRerouteChains(
graph: Pick<LGraph, 'reroutes'>
): void {
for (const reroute of graph.reroutes.values()) unregisterRerouteChain(reroute)
}

View File

@@ -0,0 +1,93 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { LGraph, LGraphNode, Reroute } from '@/lib/litegraph/src/litegraph'
import { toLinkId } from '@/types/linkId'
import { toNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import { createTestSubgraph } from '../subgraph/__fixtures__/subgraphHelpers'
import { FloatingRenderLink } from './FloatingRenderLink'
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
function inputFloatingLink(targetId: number, targetSlot: number): LLink {
return new LLink(
toLinkId(-1),
'INT',
UNASSIGNED_NODE_ID,
-1,
toNodeId(targetId),
targetSlot
)
}
describe('FloatingRenderLink', () => {
it('connectToSubgraphOutput re-targets the floating link to the output slot', () => {
const subgraph = createTestSubgraph({
nodeCount: 1,
outputs: [{ name: 'result', type: 'INT' }]
})
const [node] = subgraph.nodes
const floatingLink = inputFloatingLink(Number(node.id), 0)
floatingLink.parentId = toRerouteId(1)
const reroute = new Reroute(toRerouteId(1), subgraph, [0, 0])
subgraph._addReroute(reroute)
subgraph.addFloatingLink(floatingLink)
const renderLink = new FloatingRenderLink(
subgraph,
floatingLink,
'input',
reroute
)
renderLink.connectToSubgraphOutput(subgraph.outputs[0])
expect(floatingLink.target_id).toBe(SUBGRAPH_OUTPUT_ID)
expect(floatingLink.target_slot).toBe(0)
expect(floatingLink.origin_id).toBe(UNASSIGNED_NODE_ID)
expect(slotFloatingLinks(subgraph, 'input', node.id, 0)).toHaveLength(0)
})
})
describe('slot removal renumbers floating link attachments', () => {
it('removeInput shifts floating links on later inputs', () => {
const graph = new LGraph()
const node = new LGraphNode('N')
node.addInput('a', 'INT')
node.addInput('b', 'INT')
graph.add(node)
const floatingLink = inputFloatingLink(Number(node.id), 1)
graph.addFloatingLink(floatingLink)
node.removeInput(0)
expect(slotFloatingLinks(graph, 'input', node.id, 0)).toHaveLength(1)
expect(floatingLink.target_slot).toBe(0)
})
it('removeOutput shifts floating links on later outputs', () => {
const graph = new LGraph()
const node = new LGraphNode('N')
node.addOutput('a', 'INT')
node.addOutput('b', 'INT')
graph.add(node)
const floatingLink = new LLink(
toLinkId(-1),
'INT',
node.id,
1,
UNASSIGNED_NODE_ID,
-1
)
graph.addFloatingLink(floatingLink)
node.removeOutput(0)
expect(slotFloatingLinks(graph, 'output', node.id, 0)).toHaveLength(1)
expect(floatingLink.origin_slot).toBe(0)
})
})

View File

@@ -147,15 +147,13 @@ export class FloatingRenderLink implements RenderLink {
input: INodeInputSlot,
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
// Disconnect before re-targeting, or the floating link would be
// caught (and removed) by the target slot's floating-link cleanup.
node.disconnectInput(node.inputs.indexOf(input))
const floatingLink = this.link
floatingLink.target_id = node.id
floatingLink.target_slot = node.inputs.indexOf(input)
node.disconnectInput(node.inputs.indexOf(input))
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToOutput(
@@ -166,10 +164,6 @@ export class FloatingRenderLink implements RenderLink {
const floatingLink = this.link
floatingLink.origin_id = node.id
floatingLink.origin_slot = node.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
}
connectToSubgraphInput(
@@ -179,10 +173,6 @@ export class FloatingRenderLink implements RenderLink {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_INPUT_ID
floatingLink.origin_slot = input.parent.slots.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
}
connectToSubgraphOutput(
@@ -190,12 +180,8 @@ export class FloatingRenderLink implements RenderLink {
_events?: CustomEventTarget<LinkConnectorEventMap>
): void {
const floatingLink = this.link
floatingLink.origin_id = SUBGRAPH_OUTPUT_ID
floatingLink.origin_slot = output.parent.slots.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
floatingLink.target_id = SUBGRAPH_OUTPUT_ID
floatingLink.target_slot = output.parent.slots.indexOf(output)
}
connectToRerouteInput(
@@ -208,10 +194,6 @@ export class FloatingRenderLink implements RenderLink {
floatingLink.target_id = inputNode.id
floatingLink.target_slot = inputNode.inputs.indexOf(input)
this.fromSlot._floatingLinks?.delete(floatingLink)
input._floatingLinks ??= new Set()
input._floatingLinks.add(floatingLink)
events.dispatch('input-moved', this)
}
@@ -226,10 +208,6 @@ export class FloatingRenderLink implements RenderLink {
floatingLink.origin_id = outputNode.id
floatingLink.origin_slot = outputNode.outputs.indexOf(output)
this.fromSlot._floatingLinks?.delete(floatingLink)
output._floatingLinks ??= new Set()
output._floatingLinks.add(floatingLink)
events.dispatch('output-moved', this)
}
}

View File

@@ -1,5 +1,7 @@
// oxlint-disable no-empty-pattern
import { test as baseTest, describe, expect, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { test as baseTest, beforeEach, describe, expect, vi } from 'vitest'
import type {
MovingInputLink,
@@ -26,8 +28,11 @@ import {
createMockNodeOutputSlot
} from '@/utils/__tests__/litegraphTestUtils'
import { registerLinkTopology } from '../LLink'
import { registerRerouteChain } from '../Reroute'
interface TestContext {
network: LinkNetwork & { add(node: LGraphNode): void }
network: LinkNetwork & { add(node: LGraphNode): void; rootGraph: LGraph }
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
createTestNode: (id: number, slotType?: ISlotType) => LGraphNode
@@ -39,6 +44,8 @@ interface TestContext {
) => LLink
}
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
const test = baseTest.extend<TestContext>({
network: async ({}, use) => {
const graph = new LGraph()
@@ -59,7 +66,9 @@ const test = baseTest.extend<TestContext>({
getReroute: ((id: RerouteId | null | undefined) =>
id == null ? undefined : reroutes.get(id)) as LinkNetwork['getReroute'],
removeReroute: (id: RerouteId) => reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node)
_removeReroute: (id: RerouteId) => void reroutes.delete(id),
add: (node: LGraphNode) => graph.add(node),
rootGraph: graph
})
},
@@ -129,7 +138,7 @@ describe('LinkConnector', () => {
network.links.set(link.id, link)
targetNode.inputs[0].link = link.id
connector.moveInputLink(network, targetNode.inputs[0])
connector.moveInputLink(network, targetNode, targetNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
@@ -146,6 +155,7 @@ describe('LinkConnector', () => {
expect(() => {
connector.moveInputLink(
network,
new LGraphNode('mock'),
createMockNodeInputSlot({ link: toLinkId(1) })
)
}).toThrow('Already dragging links.')
@@ -169,7 +179,7 @@ describe('LinkConnector', () => {
network.links.set(link.id, link)
sourceNode.outputs[0].links = [link.id]
connector.moveOutputLink(network, sourceNode.outputs[0])
connector.moveOutputLink(network, sourceNode, sourceNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
@@ -187,6 +197,7 @@ describe('LinkConnector', () => {
expect(() => {
connector.moveOutputLink(
network,
new LGraphNode('mock'),
createMockNodeOutputSlot({ links: [toLinkId(1)] })
)
}).toThrow('Already dragging links.')
@@ -241,11 +252,11 @@ describe('LinkConnector', () => {
targetNode.addInput('in', 'number')
const link = createTestLink(1, 1, 2)
const reroute = new Reroute(toRerouteId(1), network, [0, 0], undefined, [
link.id
])
const reroute = new Reroute(toRerouteId(1), network, [0, 0])
network.reroutes.set(reroute.id, reroute)
registerRerouteChain(network, reroute)
link.parentId = reroute.id
registerLinkTopology(network, link)
connector.dragFromReroute(network, reroute)

View File

@@ -1,5 +1,7 @@
// oxlint-disable no-empty-pattern
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, describe, expect, vi } from 'vitest'
import type {
@@ -10,6 +12,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import { LGraphNode, LLink, LinkConnector } from '@/lib/litegraph/src/litegraph'
import { slotFloatingLinks } from '../LLink'
import { test as baseTest } from '../__fixtures__/testExtensions'
import type { ConnectingLink } from '@/lib/litegraph/src/interfaces'
import { toLinkId } from '@/types/linkId'
@@ -21,6 +24,7 @@ import {
} from '@/utils/__tests__/litegraphTestUtils'
interface TestContext {
pinia: undefined
graph: LGraph
connector: LinkConnector
setConnectingLinks: (value: ConnectingLink[]) => void
@@ -30,13 +34,21 @@ interface TestContext {
validateIntegrityFloatingRemoved: () => void
validateLinkIntegrity: () => void
getNextLinkIds: (
linkIds: Set<number>,
linkIds: ReadonlySet<number>,
expectedExtraLinks?: number
) => number[]
readonly floatingReroute: Reroute
}
const test = baseTest.extend<TestContext>({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
await use(undefined)
},
{ auto: true }
],
reroutesBeforeTest: async ({ reroutesComplexGraph }, use) => {
await use([...reroutesComplexGraph.reroutes])
},
@@ -157,18 +169,23 @@ const test = baseTest.extend<TestContext>({
expect(link.origin_id).not.toBe(UNASSIGNED_NODE_ID)
expect(link.origin_slot).not.toBe(-1)
expect(link.target_slot).toBe(-1)
const outputFloatingLinks = graph.getNodeById(link.origin_id)
?.outputs[link.origin_slot]._floatingLinks
expect(outputFloatingLinks).toBeDefined()
const outputFloatingLinks = slotFloatingLinks(
graph,
'output',
link.origin_id,
link.origin_slot
)
expect(outputFloatingLinks).toContain(link)
} else {
expect(link.origin_id).toBe(UNASSIGNED_NODE_ID)
expect(link.origin_slot).toBe(-1)
expect(link.target_slot).not.toBe(-1)
const inputFloatingLinks = graph.getNodeById(link.target_id)?.inputs[
const inputFloatingLinks = slotFloatingLinks(
graph,
'input',
link.target_id,
link.target_slot
]._floatingLinks
expect(inputFloatingLinks).toBeDefined()
)
expect(inputFloatingLinks).toContain(link)
}
}
@@ -229,7 +246,7 @@ describe('LinkConnector Integration', () => {
graph.links.get(hasInputNode.inputs[0].link!)!
)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
expect(connector.state.connectingTo).toBe('input')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.renderLinks.length).toBe(1)
@@ -335,9 +352,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})
@@ -356,7 +377,7 @@ describe('LinkConnector Integration', () => {
const atOutputNodeEvent = mockedNodeTitleDropEvent(hasOutputNode)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
connector.dropLinks(graph, atOutputNodeEvent)
connector.reset()
@@ -385,7 +406,7 @@ describe('LinkConnector Integration', () => {
const atHasOutputNode = mockedInputDropEvent(hasOutputNode, 0)
connector.moveInputLink(graph, hasInputNode.inputs[0])
connector.moveInputLink(graph, hasInputNode, hasInputNode.inputs[0])
connector.dropLinks(graph, atHasOutputNode)
connector.reset()
@@ -411,7 +432,7 @@ describe('LinkConnector Integration', () => {
?.map((linkId) => graph.links.get(linkId)!)
.map((link) => LLink.getReroutes(graph, link))
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
expect(connector.state.connectingTo).toBe('output')
expect(connector.state.draggingExistingLinks).toBe(true)
expect(connector.renderLinks.length).toBe(3)
@@ -540,9 +561,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})
@@ -560,7 +585,11 @@ describe('LinkConnector Integration', () => {
canvasY
)
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(
graph,
manyOutputsNode,
manyOutputsNode.outputs[0]
)
connector.dropLinks(graph, floatingRerouteEvent)
connector.reset()
@@ -595,7 +624,11 @@ describe('LinkConnector Integration', () => {
manyOutputsNode.outputs[0].links!
)
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(
graph,
manyOutputsNode,
manyOutputsNode.outputs[0]
)
expect(connector.isRerouteValidDrop(reroute7)).toBe(false)
expect(connector.isRerouteValidDrop(reroute10)).toBe(false)
expect(connector.isRerouteValidDrop(reroute13)).toBe(false)
@@ -632,7 +665,7 @@ describe('LinkConnector Integration', () => {
const atInputNodeEvent = mockedNodeTitleDropEvent(hasInputNode)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeEvent)
connector.reset()
@@ -670,7 +703,7 @@ describe('LinkConnector Integration', () => {
const atInputNodeOutSlot = mockedOutputDropEvent(hasInputNode, 0)
connector.moveOutputLink(graph, hasOutputNode.outputs[0])
connector.moveOutputLink(graph, hasOutputNode, hasOutputNode.outputs[0])
connector.dropLinks(graph, atInputNodeOutSlot)
connector.reset()
@@ -747,16 +780,20 @@ describe('LinkConnector Integration', () => {
const hasInputNode = graph.getNodeById(toNodeId(2))!
const toInput = hasInputNode.inputs[0]
connector.moveInputLink(graph, fromFloatingInput)
connector.moveInputLink(graph, floatingInputNode, fromFloatingInput)
const dropEvent = mockedInputDropEvent(hasInputNode, 0)
connector.dropLinks(graph, dropEvent)
connector.reset()
expect(fromFloatingInput.link).toBeNull()
expect(fromFloatingInput._floatingLinks?.size).toBe(0)
expect(
slotFloatingLinks(graph, 'input', floatingInputNode.id, 0)
).toHaveLength(0)
expect(toInput.link).toBeNull()
expect(toInput._floatingLinks?.size).toBe(1)
expect(
slotFloatingLinks(graph, 'input', hasInputNode.id, 0)
).toHaveLength(1)
})
test('Allow reroutes to be used as manual switches', ({
@@ -806,7 +843,7 @@ describe('LinkConnector Integration', () => {
validateIntegrityNoChanges
}) => {
const floatingOutNode = graph.getNodeById(toNodeId(1))!
connector.moveOutputLink(graph, floatingOutNode.outputs[0])
connector.moveOutputLink(graph, floatingOutNode, floatingOutNode.outputs[0])
const manyOutputsNode = graph.getNodeById(toNodeId(4))!
const dropEvent = createMockCanvasPointerEvent(
@@ -818,12 +855,14 @@ describe('LinkConnector Integration', () => {
const output = manyOutputsNode.outputs[0]
expect(output.links!.length).toBe(6)
expect(output._floatingLinks!.size).toBe(1)
expect(
slotFloatingLinks(graph, 'output', manyOutputsNode.id, 0)
).toHaveLength(1)
validateIntegrityNoChanges()
// Move again
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
connector.moveOutputLink(graph, manyOutputsNode, manyOutputsNode.outputs[0])
const disconnectedNode = graph.getNodeById(toNodeId(9))!
const dropEvent2 = createMockCanvasPointerEvent(
@@ -835,13 +874,17 @@ describe('LinkConnector Integration', () => {
const newOutput = disconnectedNode.outputs[0]
expect(newOutput.links!.length).toBe(6)
expect(newOutput._floatingLinks!.size).toBe(1)
expect(
slotFloatingLinks(graph, 'output', disconnectedNode.id, 0)
).toHaveLength(1)
validateIntegrityNoChanges()
disconnectedNode.disconnectOutput(0)
expect(newOutput._floatingLinks!.size).toBe(0)
expect(
slotFloatingLinks(graph, 'output', disconnectedNode.id, 0)
).toHaveLength(0)
expect(graph.floatingLinks.size).toBe(6)
// The final reroutes should all be floating
@@ -867,9 +910,13 @@ describe('LinkConnector Integration', () => {
expect([0, undefined]).toContain(output.links?.length)
expect([0, undefined]).toContain(input._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'input', toNodeId(nodeId), 0)
).toHaveLength(0)
expect([0, undefined]).toContain(output._floatingLinks?.size)
expect(
slotFloatingLinks(graph, 'output', toNodeId(nodeId), 0)
).toHaveLength(0)
}
})

View File

@@ -1,6 +1,9 @@
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LinkConnector } from '@/lib/litegraph/src/litegraph'
import type { Reroute } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
createMockLGraphNode,
createMockLinkNetwork,
@@ -13,8 +16,19 @@ const mockSetConnectingLinks = vi.fn()
type RenderLinkItem = LinkConnector['renderLinks'][number]
interface MockRenderLinkOptions {
canConnectToOutput?: boolean
canConnectToReroute?: boolean
}
// Mock a structure that has the needed method
function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
function mockRenderLinkImpl(
canConnect: boolean,
{
canConnectToOutput = false,
canConnectToReroute = false
}: MockRenderLinkOptions = {}
): RenderLinkItem {
const partial: Partial<RenderLinkItem> = {
toType: 'output',
fromPos: [0, 0],
@@ -25,8 +39,8 @@ function mockRenderLinkImpl(canConnect: boolean): RenderLinkItem {
fromSlot: createMockNodeOutputSlot(),
dragDirection: 0,
canConnectToInput: vi.fn().mockReturnValue(canConnect),
canConnectToOutput: vi.fn().mockReturnValue(false),
canConnectToReroute: vi.fn().mockReturnValue(false),
canConnectToOutput: vi.fn().mockReturnValue(canConnectToOutput),
canConnectToReroute: vi.fn().mockReturnValue(canConnectToReroute),
connectToInput: vi.fn(),
connectToOutput: vi.fn(),
connectToSubgraphInput: vi.fn(),
@@ -73,4 +87,33 @@ describe('LinkConnector', () => {
expect(link2.canConnectToInput).toHaveBeenCalledWith(mockNode, mockInput)
})
})
describe('dropOnReroute', () => {
test('skips render links that cannot connect to the reroute', () => {
const valid = mockRenderLinkImpl(false, {
canConnectToOutput: true,
canConnectToReroute: true
})
const invalid = mockRenderLinkImpl(false, {
canConnectToOutput: true,
canConnectToReroute: false
})
connector.renderLinks.push(valid, invalid)
connector.state.connectingTo = 'output'
const output = createMockNodeOutputSlot()
const reroute = fromPartial<Reroute>({
findSourceOutput: () => ({ node: mockNode, output })
})
connector.dropOnReroute(reroute, fromPartial<CanvasPointerEvent>({}))
expect(valid.connectToRerouteOutput).toHaveBeenCalledWith(
reroute,
mockNode,
output,
connector.events
)
expect(invalid.connectToRerouteOutput).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,7 +1,7 @@
import { remove } from 'es-toolkit'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import {
SUBGRAPH_INPUT_ID,
@@ -135,6 +135,7 @@ export class LinkConnector {
/** Drag an existing link to a different input. */
moveInputLink(
network: LinkNetwork,
node: LGraphNode,
input: INodeInputSlot,
opts?: { startPoint?: Point }
): void {
@@ -145,7 +146,12 @@ export class LinkConnector {
const linkId = input.link
if (linkId == null) {
// No link connected, check for a floating link
const floatingLink = input._floatingLinks?.values().next().value
const [floatingLink] = slotFloatingLinks(
network,
'input',
node.id,
node.inputs.indexOf(input)
)
if (floatingLink?.parentId == null) return
try {
@@ -270,42 +276,49 @@ export class LinkConnector {
}
/** Drag all links from an output to a new output. */
moveOutputLink(network: LinkNetwork, output: INodeOutputSlot): void {
moveOutputLink(
network: LinkNetwork,
node: LGraphNode,
output: INodeOutputSlot
): void {
if (this.isConnecting) throw new Error('Already dragging links.')
const { state, renderLinks } = this
// Floating links
if (output._floatingLinks?.size) {
for (const floatingLink of output._floatingLinks.values()) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
for (const floatingLink of slotFloatingLinks(
network,
'output',
node.id,
node.outputs.indexOf(output)
)) {
try {
const reroute = LLink.getFirstReroute(network, floatingLink)
if (!reroute)
throw new Error(
`Invalid reroute id: [${floatingLink.parentId}] for floating link id: [${floatingLink.id}].`
)
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
const renderLink = new FloatingRenderLink(
network,
floatingLink,
'output',
reroute
)
const mayContinue = this.events.dispatch(
'before-move-output',
renderLink
)
if (mayContinue === false) continue
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
renderLinks.push(renderLink)
this.floatingLinks.push(floatingLink)
} catch (error) {
console.warn(
`Could not create render link for link id: [${floatingLink.id}].`,
floatingLink,
error
)
}
}
@@ -806,20 +819,31 @@ export class LinkConnector {
return
}
// Connecting to output
for (const link of this.renderLinks) {
if (link.toType !== 'output') continue
const result = reroute.findSourceOutput()
if (!result) continue
const { node, output } = result
if (!link.canConnectToOutput(node, output)) continue
// Connecting to output.
const drop = this._resolveOutputDrop(reroute)
if (!drop) return
const { node, output, links } = drop
for (const link of links) {
link.connectToRerouteOutput(reroute, node, output, this.events)
}
}
/** Resolves once: connecting a link re-anchors its chain, changing membership. */
private _resolveOutputDrop(reroute: Reroute) {
const result = reroute.findSourceOutput()
if (!result) return
const { node, output } = result
const links = this.renderLinks.filter(
(link) =>
link.toType === 'output' &&
link.canConnectToReroute(reroute) &&
link.canConnectToOutput(node, output)
)
return { node, output, links }
}
/** @internal Temporary workaround - requires refactor. */
_connectOutputToReroute(reroute: Reroute, renderLink: RenderLinkUnion): void {
const results = reroute.findTargetInputs()
@@ -832,20 +856,9 @@ export class LinkConnector {
// From reroute to reroute
if (renderLink instanceof ToInputRenderLink) {
const { node, fromSlot, fromSlotIndex, fromReroute } = renderLink
const { node, fromSlotIndex } = renderLink
reroute.setFloatingLinkOrigin(node, fromSlot, fromSlotIndex)
// Clean floating link IDs from reroutes about to be removed from the chain
if (fromReroute != null) {
for (const originalReroute of originalReroutes) {
if (originalReroute.id === fromReroute.id) break
for (const linkId of reroute.floatingLinkIds) {
originalReroute.floatingLinkIds.delete(linkId)
}
}
}
reroute.setFloatingLinkOrigin(node, fromSlotIndex)
}
// Filter before any connections are re-created
@@ -1016,16 +1029,7 @@ export class LinkConnector {
}
}
} else {
const result = reroute.findSourceOutput()
if (!result) return false
const { node, output } = result
for (const renderLink of this.renderLinks) {
if (renderLink.toType !== 'output') continue
if (!renderLink.canConnectToReroute(reroute)) continue
if (renderLink.canConnectToOutput(node, output)) return true
}
return !!this._resolveOutputDrop(reroute)?.links.length
}
return false

View File

@@ -1,4 +1,6 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
@@ -31,6 +33,7 @@ describe('LinkConnector SubgraphInput connection validation', () => {
const mockSetConnectingLinks = vi.fn()
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
connector = new LinkConnector(mockSetConnectingLinks)
vi.clearAllMocks()
})

View File

@@ -120,7 +120,6 @@ export class ToInputFromIoNodeLink implements RenderLink {
for (const reroute of originalReroutes) {
if (reroute.id === fromReroute?.id) break
reroute.removeLink(link)
if (reroute.totalLinks === 0) {
if (link.isFloating) {
// Cannot float from both sides - remove

View File

@@ -112,7 +112,6 @@ export class ToInputRenderLink implements RenderLink {
for (const reroute of originalReroutes) {
if (reroute.id === fromReroute?.id) break
reroute.removeLink(link)
if (reroute.totalLinks === 0) {
if (link.isFloating) {
// Cannot float from both sides - remove

View File

@@ -183,6 +183,8 @@ export interface LinkNetwork extends ReadonlyLinkNetwork {
readonly reroutes: Map<RerouteId, Reroute>
addFloatingLink(link: LLink): LLink
removeReroute(id: RerouteId): unknown
/** Removes a reroute from the map and its stores, without chain splicing. */
_removeReroute(id: RerouteId): void
removeFloatingLink(link: LLink): void
}
@@ -329,11 +331,6 @@ export interface INodeSlot extends HasBoundingRect {
pos?: Point
/** @remarks Automatically calculated; not included in serialisation. */
boundingRect: ReadOnlyRect
/**
* A list of floating link IDs that are connected to this slot.
* This is calculated at runtime; it is **not** serialized.
*/
_floatingLinks?: Set<LLink>
/**
* Whether the slot has errors. It is **not** serialized.
*/

View File

@@ -1,5 +1,7 @@
import { registerLinkTopology } from './LLink'
import type { LGraph } from './LGraph'
import type { LGraphNode } from './LGraphNode'
import type { SerializedNodeId } from '@/types/nodeId'
import type { LLink, LinkId } from './LLink'
/** Generates a unique string key for a link's connection tuple. */
@@ -39,20 +41,23 @@ export function selectSurvivorLink(
return ids[0]
}
/** Removes duplicate links from origin outputs and the graph's link map. */
/**
* Removes duplicate links from origin outputs and the graph, routing map
* removal through {@link LGraph._removeLink} so the link and layout stores
* stay in sync.
*/
export function purgeOrphanedLinks(
ids: LinkId[],
keepId: LinkId,
links: Map<LinkId, LLink>,
getNodeById: (id: SerializedNodeId) => LGraphNode | null
graph: LGraph
): void {
for (const id of ids) {
if (id === keepId) continue
const link = links.get(id)
const link = graph._links.get(id)
if (!link) continue
const originNode = getNodeById(link.origin_id)
const originNode = graph.getNodeById(link.origin_id)
const output = originNode?.outputs?.[link.origin_slot]
if (output?.links) {
for (let i = output.links.length - 1; i >= 0; i--) {
@@ -60,8 +65,13 @@ export function purgeOrphanedLinks(
}
}
links.delete(id)
graph._removeLink(id)
}
// Purging a duplicate that owned the survivor's target-slot index entry
// removes that entry, so re-assert the survivor's registration afterwards.
const survivor = graph._links.get(keepId)
if (survivor) registerLinkTopology(graph, survivor)
}
/** Ensures input.link on the target node points to the surviving link. */

View File

@@ -1,4 +1,3 @@
import type { LLink } from '@/lib/litegraph/src/LLink'
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
import type {
CanvasColour,
@@ -28,7 +27,6 @@ export abstract class SlotBase implements INodeSlot {
locked?: boolean
nameLocked?: boolean
widget?: IWidgetLocator
_floatingLinks?: Set<LLink>
hasErrors?: boolean
/** The centre point of the slot. */

View File

@@ -1,4 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode, LiteGraph } from './litegraph'
import type { NodeInputSlot } from './node/NodeInputSlot'
@@ -14,6 +16,8 @@ class TestNode extends LGraphNode {
LiteGraph.registerNodeType('test/TestNode', TestNode)
describe('Serialization - Circular Reference Prevention', () => {
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
describe('LGraph.toJSON()', () => {
it('should serialize without circular reference errors', () => {
const graph = new LGraph()

View File

@@ -8,6 +8,7 @@ import {
LiteGraph
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { toRerouteId } from '@/types/rerouteId'
import {
createTestSubgraph,
@@ -203,5 +204,64 @@ describe('SubgraphConversion', () => {
}
expect(linkRefCount).toBe(4)
})
it('Should truncate cyclic reroute chains instead of aborting unpack', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
const first = subgraph.createReroute([10, 10], innerLink)!
const second = subgraph.createReroute([20, 20], first)!
// Simulate corrupt data: first → second → first
second._chain.parentId = first.id
expect(() => graph.unpackSubgraph(subgraphNode)).not.toThrow()
expect(graph.nodes.length).toBe(2)
expect(graph.links.size).toBe(1)
expect(graph.reroutes.size).toBe(2)
const [link] = [...graph.links.values()]
assert(link.parentId !== undefined)
expect(graph.reroutes.get(link.parentId)).toBeDefined()
})
it('Should not stitch broken external parentId references onto merged links', () => {
const subgraph = createTestSubgraph({
outputs: [{ name: 'value', type: 'number' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const inner = createNode(subgraph, [], ['number'])
const innerLink = subgraph.outputNode.slots[0].connect(
inner.outputs[0],
inner
)
assert(innerLink)
const outer = createNode(graph, ['number'])
const outerLink = subgraphNode.connect(0, outer, 0)
assert(outerLink)
// Simulate corrupt data: the external chain names a missing reroute
outerLink.parentId = toRerouteId(999)
graph.unpackSubgraph(subgraphNode)
expect(graph.links.size).toBe(1)
const [link] = [...graph.links.values()]
expect(link.parentId).toBeUndefined()
})
})
})

View File

@@ -1,6 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { anchorRerouteChain } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import { CustomEventTarget } from '@/lib/litegraph/src/infrastructure/CustomEventTarget'
import type { SubgraphInputEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphInputEventMap'
@@ -114,30 +115,13 @@ export class SubgraphInput extends SubgraphSlot {
)
// Add to graph links list
subgraph._links.set(link.id, link)
subgraph._addLink(link)
// Set link ID in each slot
this.linkIds.push(link.id)
slot.link = link.id
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(subgraph, link)
subgraph.incrementVersion()
subgraph.trigger('node:slot-links:changed', {

View File

@@ -1,7 +1,7 @@
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { NodeId } from '@/types/nodeId'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type { LinkConnector } from '@/lib/litegraph/src/canvas/LinkConnector'
@@ -179,10 +179,14 @@ export class SubgraphInputNode
const { subgraph } = this
// Break floating links
if (input._floatingLinks?.size) {
for (const link of input._floatingLinks) {
subgraph.removeFloatingLink(link)
}
const inputIndex = node.inputs.indexOf(input)
for (const floatingLink of slotFloatingLinks(
subgraph,
'input',
node.id,
inputIndex
)) {
subgraph.removeFloatingLink(floatingLink)
}
input.link = null
@@ -222,11 +226,10 @@ export class SubgraphInputNode
input: subgraphInput
})
const slotIndex = node.inputs.findIndex((inp) => inp === input)
if (slotIndex !== -1) {
if (inputIndex !== -1) {
node.onConnectionsChange?.(
NodeSlotType.INPUT,
slotIndex,
inputIndex,
false,
link,
subgraphInput
@@ -234,7 +237,7 @@ export class SubgraphInputNode
subgraph.trigger('node:slot-links:changed', {
nodeId: node.id,
slotType: NodeSlotType.INPUT,
slotIndex: slotIndex,
slotIndex: inputIndex,
connected: false,
linkId: link.id
})

View File

@@ -32,6 +32,7 @@ import type {
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
@@ -643,20 +644,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (inputWidget) Object.setPrototypeOf(input.widget, inputWidget)
const id = widgetId(this.rootGraph.id, this.id, subgraphInput.name)
const store = useWidgetValueStore()
input.widgetId = id
useWidgetValueStore().registerWidget(id, {
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled,
isDOMWidget:
'isDOMWidget' in interiorWidget &&
typeof interiorWidget.isDOMWidget === 'boolean'
? interiorWidget.isDOMWidget
: undefined
})
store.registerWidget(
id,
{
type: interiorWidget.type,
value: interiorWidget.value,
options: cloneDeep(interiorWidget.options ?? {}),
label: input.label ?? subgraphInput.name,
serialize: interiorWidget.serialize,
disabled: interiorWidget.disabled
},
deriveWidgetRenderState(interiorWidget)
)
input._widget =
this.createPromotedHostWidget(input, id, interiorWidget) ??
this._projectPromotedWidget(input)

View File

@@ -3,6 +3,7 @@ import { pull } from 'es-toolkit/compat'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { toLinkId } from '@/types/linkId'
import { anchorRerouteChain } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
import type {
INodeInputSlot,
@@ -78,31 +79,14 @@ export class SubgraphOutput extends SubgraphSlot {
)
// Add to graph links list
subgraph._links.set(link.id, link)
subgraph._addLink(link)
// Set link ID in each slot
this.linkIds[0] = link.id
slot.links ??= []
slot.links.push(link.id)
// Reroutes
const reroutes = LLink.getReroutes(subgraph, link)
for (const reroute of reroutes) {
reroute.linkIds.add(link.id)
if (reroute.floating) delete reroute.floating
reroute._dragging = undefined
}
// If this is the terminus of a floating link, remove it
const lastReroute = reroutes.at(-1)
if (lastReroute) {
for (const linkId of lastReroute.floatingLinkIds) {
const link = subgraph.floatingLinks.get(linkId)
if (link?.parentId === lastReroute.id) {
subgraph.removeFloatingLink(link)
}
}
}
anchorRerouteChain(subgraph, link)
subgraph.incrementVersion()
node.onConnectionsChange?.(

View File

@@ -500,6 +500,36 @@ describe('SubgraphSerialization - Data Integrity', () => {
}
})
it('serializes interior links with contract key order and round-trips byte-identically', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
const first = subgraph.asSerialisable()
expect(first.links?.length).toBe(2)
for (const link of first.links ?? []) {
expect(Object.keys(link)).toEqual([
'id',
'origin_id',
'origin_slot',
'target_id',
'target_slot',
'type'
])
}
const restored = new Subgraph(new LGraph(), first)
restored.configure(first)
const second = restored.asSerialisable()
expect(JSON.stringify(second.links)).toBe(JSON.stringify(first.links))
})
it('deduplicates duplicate subgraph node IDs while keeping root nodes canonical', () => {
const graph = new LGraph()
graph.configure(structuredClone(duplicateSubgraphNodeIds))

View File

@@ -154,7 +154,11 @@ describe('Subgraph slot connections', () => {
const connector = new LinkConnector(setConnectingLinks)
// Now try to drag from the input slot
connector.moveInputLink(subgraph as LinkNetwork, internalNode.inputs[0])
connector.moveInputLink(
subgraph as LinkNetwork,
internalNode,
internalNode.inputs[0]
)
// Verify that we're dragging the existing link
expect(connector.isConnecting).toBe(true)

View File

@@ -4,9 +4,20 @@ import {
} from '@/lib/litegraph/src/constants'
import { describe, expect, it } from 'vitest'
import type { ExportedSubgraph } from '../types/serialisation'
import { toLinkId } from '@/types/linkId'
import { toRerouteId } from '@/types/rerouteId'
import { topologicalSortSubgraphs } from './subgraphDeduplication'
import type { LGraphState } from '../LGraph'
import type {
ExportedSubgraph,
SerialisableLLink,
SerialisableReroute
} from '../types/serialisation'
import {
deduplicateSubgraphRerouteIds,
topologicalSortSubgraphs
} from './subgraphDeduplication'
function makeSubgraph(id: string, nodeTypes: string[] = []): ExportedSubgraph {
return {
@@ -78,3 +89,100 @@ describe('topologicalSortSubgraphs', () => {
expect(topologicalSortSubgraphs([])).toEqual([])
})
})
function reroute(
id: number,
parentId?: number,
linkIds: number[] = []
): SerialisableReroute {
return { id, parentId, pos: [0, 0], linkIds }
}
function chainedLink(id: number, parentId?: number): SerialisableLLink {
return {
id: toLinkId(id),
origin_id: 1,
origin_slot: 0,
target_id: 2,
target_slot: 0,
type: 'INT',
parentId: parentId === undefined ? undefined : toRerouteId(parentId)
}
}
function freshState(lastRerouteId = 0): LGraphState {
return {
lastGroupId: 0,
lastNodeId: 0,
lastLinkId: toLinkId(0),
lastRerouteId: toRerouteId(lastRerouteId)
}
}
describe('deduplicateSubgraphRerouteIds', () => {
it('remaps colliding reroute ids and patches parentId references', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1, undefined, [1]), reroute(2, 1, [1])]
subgraph.links = [chainedLink(1, 2)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1]), state)
const [first, second] = subgraph.reroutes
expect(first.id).not.toBe(1)
expect(second.parentId).toBe(first.id)
expect(subgraph.links[0].parentId).toBe(second.id)
expect(Number(state.lastRerouteId)).toBeGreaterThanOrEqual(first.id)
})
it('remaps chained collisions created by the remap itself', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1), reroute(2, 1)]
subgraph.links = [chainedLink(1, 2)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1, 2]), state)
const ids = subgraph.reroutes.map((r) => r.id)
expect(new Set(ids).size).toBe(2)
expect(ids).not.toContain(1)
expect(ids).not.toContain(2)
expect(subgraph.reroutes[1].parentId).toBe(subgraph.reroutes[0].id)
expect(subgraph.links[0].parentId).toBe(subgraph.reroutes[1].id)
})
it('keeps sibling subgraphs from colliding with each other', () => {
const first = makeSubgraph('first')
first.reroutes = [reroute(1)]
const second = makeSubgraph('second')
second.reroutes = [reroute(1)]
const state = freshState(0)
deduplicateSubgraphRerouteIds([first, second], new Set(), state)
expect(first.reroutes[0].id).toBe(1)
expect(second.reroutes[0].id).not.toBe(1)
})
it('patches floating link parentId references', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(1)]
subgraph.floatingLinks = [chainedLink(1, 1)]
const state = freshState(1)
deduplicateSubgraphRerouteIds([subgraph], new Set([1]), state)
expect(subgraph.floatingLinks[0].parentId).toBe(subgraph.reroutes[0].id)
})
it('reserves non-colliding ids and advances the shared counter', () => {
const subgraph = makeSubgraph('sg')
subgraph.reroutes = [reroute(7)]
const state = freshState(0)
deduplicateSubgraphRerouteIds([subgraph], new Set(), state)
expect(subgraph.reroutes[0].id).toBe(7)
expect(Number(state.lastRerouteId)).toBe(7)
})
})

View File

@@ -1,6 +1,7 @@
import type { LGraphState } from '../LGraph'
import { toNodeId } from '@/types/nodeId'
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -8,7 +9,7 @@ import type {
SerialisableLLink
} from '../types/serialisation'
const MAX_NODE_ID = 100_000_000
const MAX_ID = 100_000_000
interface DeduplicationResult {
subgraphs: ExportedSubgraph[]
@@ -81,7 +82,7 @@ function remapNodeIds(
const numericId = numericSerializedNodeId(id)
if (usedNodeIdKeys.has(key)) {
const newId = findNextAvailableId(usedNodeIds, state)
const newId = findNextAvailableId(usedNodeIds, () => ++state.lastNodeId)
remappedIds.set(key, newId)
node.id = newId
usedNodeIds.add(newId)
@@ -101,10 +102,7 @@ function remapNodeIds(
return remappedIds
}
/**
* Finds the next unused node ID by incrementing `state.lastNodeId`.
* Throws if the ID space is exhausted.
*/
/** Parses a serialized node ID as an integer, or `null` when non-numeric. */
function numericSerializedNodeId(id: SerializedNodeId): number | null {
const key = toNodeId(id)
const numericId = Number(key)
@@ -113,17 +111,20 @@ function numericSerializedNodeId(id: SerializedNodeId): number | null {
: null
}
/**
* Finds the next unused ID by repeatedly calling `advance`.
* Throws if the ID space is exhausted.
*/
function findNextAvailableId(
usedNodeIds: Set<number>,
state: LGraphState
usedIds: Set<number>,
advance: () => number
): number {
while (true) {
const nextId = state.lastNodeId + 1
if (nextId > MAX_NODE_ID) {
const nextId = advance()
if (nextId > MAX_ID) {
throw new Error('Node ID space exhausted')
}
state.lastNodeId = nextId
if (!usedNodeIds.has(nextId)) return nextId
if (!usedIds.has(nextId)) return nextId
}
}
@@ -152,6 +153,87 @@ function patchPromotedWidgets(
}
}
/**
* Dedupes reroute IDs across serialized subgraph definitions. Reroute IDs
* must be unique within a root graph: the reroute store keys every reroute
* in a root graph (its subgraphs' included) in one bucket, but subgraph
* definitions from older frontends or external tools may number their
* reroutes from scratch. Remaps colliding IDs in place and patches every
* reference within the subgraph (`reroute.parentId`, `link.parentId`),
* advancing `state.lastRerouteId`.
*/
export function deduplicateSubgraphRerouteIds(
subgraphs: ExportedSubgraph[],
reservedRerouteIds: Set<number>,
state: LGraphState
): void {
const usedRerouteIds = new Set(reservedRerouteIds)
for (const id of reservedRerouteIds) reserveRerouteId(id, state)
for (const subgraph of subgraphs) {
const remapped = remapRerouteIds(subgraph, usedRerouteIds, state)
if (remapped.size > 0) patchRerouteReferences(subgraph, remapped)
}
}
/** Advances `state.lastRerouteId` so future allocations skip `id`. */
function reserveRerouteId(id: number, state: LGraphState): void {
if (id > state.lastRerouteId) state.lastRerouteId = toRerouteId(id)
}
/**
* Remaps duplicate reroute IDs to unique values, updating `usedRerouteIds`
* and `state.lastRerouteId` as new IDs are allocated.
* @returns A map of old ID → new ID for reroutes that were remapped.
*/
function remapRerouteIds(
subgraph: ExportedSubgraph,
usedRerouteIds: Set<number>,
state: LGraphState
): Map<number, number> {
const remapped = new Map<number, number>()
for (const reroute of subgraph.reroutes ?? []) {
if (usedRerouteIds.has(reroute.id)) {
const newId = findNextAvailableId(usedRerouteIds, () => {
state.lastRerouteId = toRerouteId(state.lastRerouteId + 1)
return state.lastRerouteId
})
remapped.set(reroute.id, newId)
console.warn(
`LiteGraph: duplicate subgraph reroute ID ${reroute.id} remapped to ${newId}`
)
reroute.id = newId
usedRerouteIds.add(newId)
} else {
usedRerouteIds.add(reroute.id)
reserveRerouteId(reroute.id, state)
}
}
return remapped
}
/** Patches every reference to a remapped reroute ID within a subgraph. */
function patchRerouteReferences(
subgraph: ExportedSubgraph,
remapped: Map<number, number>
): void {
for (const reroute of subgraph.reroutes ?? []) {
if (reroute.parentId === undefined) continue
const newParentId = remapped.get(reroute.parentId)
if (newParentId !== undefined) reroute.parentId = newParentId
}
for (const link of [
...(subgraph.links ?? []),
...(subgraph.floatingLinks ?? [])
]) {
if (link.parentId === undefined) continue
const newParentId = remapped.get(link.parentId)
if (newParentId !== undefined) link.parentId = toRerouteId(newParentId)
}
}
/**
* Topologically sorts subgraph definitions so that leaf subgraphs (those
* that no other subgraph depends on) are configured first. This ensures

View File

@@ -2,7 +2,7 @@ import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import type { ResolvedConnection } from '@/lib/litegraph/src/LLink'
import { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
@@ -114,8 +114,10 @@ export function getBoundaryLinks(
// Inputs
if (node.inputs) {
for (const input of node.inputs) {
addFloatingLinks(input._floatingLinks)
for (const [inputIndex, input] of node.inputs.entries()) {
addFloatingLinks(
slotFloatingLinks(graph, 'input', node.id, inputIndex)
)
if (input.link == null) continue
@@ -142,8 +144,10 @@ export function getBoundaryLinks(
// Outputs
if (node.outputs) {
for (const output of node.outputs) {
addFloatingLinks(output._floatingLinks)
for (const [outputIndex, output] of node.outputs.entries()) {
addFloatingLinks(
slotFloatingLinks(graph, 'output', node.id, outputIndex)
)
if (!output.links) continue
@@ -204,9 +208,7 @@ export function getBoundaryLinks(
* Adds any floating links that cross the boundary.
* @param floatingLinks The floating links to check
*/
function addFloatingLinks(floatingLinks: Set<LLink> | undefined): void {
if (!floatingLinks) return
function addFloatingLinks(floatingLinks: LLink[]): void {
for (const link of floatingLinks) {
const crossesBoundary = LLink.getReroutes(graph, link).some(
(reroute) => !items.has(reroute)

View File

@@ -68,14 +68,14 @@ export interface SerialisableGraph extends BaseExportedGraph {
export type ISerialisableNodeInput = Omit<
INodeInputSlot,
'boundingRect' | 'widget' | 'link' | '_floatingLinks'
'boundingRect' | 'widget' | 'link'
> & {
link?: number | null
widget?: { name: string }
}
export type ISerialisableNodeOutput = Omit<
INodeOutputSlot,
'boundingRect' | '_data' | 'links' | '_floatingLinks'
'boundingRect' | '_data' | 'links'
> & {
links?: number[] | null
widget?: { name: string }
@@ -164,7 +164,7 @@ export interface ExportedSubgraph extends SerialisableGraph {
/** Properties shared by subgraph and node I/O slots. */
type SubgraphIOShared = Omit<
INodeSlot,
'boundingRect' | 'nameLocked' | 'locked' | 'removable' | '_floatingLinks'
'boundingRect' | 'nameLocked' | 'locked' | 'removable'
>
/** Subgraph I/O slots */

View File

@@ -1,5 +1,10 @@
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { WidgetRenderState } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import type { UUID } from '@/utils/uuid'
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
@@ -24,6 +29,35 @@ export function evaluateInput(input: string): number | undefined {
return newValue
}
export function getWidgetIds(
widgets: readonly { readonly widgetId?: WidgetId }[]
): WidgetId[] {
return widgets
.map((widget) => widget.widgetId)
.filter((id): id is WidgetId => id !== undefined)
}
function isDOMBackedWidget(widget: Readonly<IBaseWidget>): boolean {
if ('isDOMWidget' in widget && typeof widget.isDOMWidget === 'boolean') {
return widget.isDOMWidget
}
return (
('element' in widget && !!widget.element) ||
('component' in widget && !!widget.component)
)
}
export function deriveWidgetRenderState(
widget: Readonly<IBaseWidget>
): WidgetRenderState {
return {
advanced: widget.options?.advanced ?? widget.advanced,
hasLayoutSize: typeof widget.computeLayoutSize === 'function',
isDOMWidget: isDOMBackedWidget(widget),
tooltip: widget.tooltip
}
}
export function resolveNodeRootGraphId(
node: Pick<LGraphNode, 'graph'>
): UUID | undefined

View File

@@ -4,7 +4,15 @@ import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
import type {
IBaseWidget,
INumericWidget
} from '@/lib/litegraph/src/types/widgets'
import { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import type {
DrawWidgetOptions,
WidgetEventOptions
} from '@/lib/litegraph/src/widgets/BaseWidget'
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { toNodeId } from '@/types/nodeId'
@@ -27,6 +35,28 @@ function createTestWidget(
)
}
class MutableTypeWidget extends BaseWidget<IBaseWidget<number, string>> {
drawWidget(
_ctx: CanvasRenderingContext2D,
_options: DrawWidgetOptions
): void {}
onClick(_options: WidgetEventOptions): void {}
}
function createMutableTypeWidget(node: LGraphNode): MutableTypeWidget {
return new MutableTypeWidget(
{
type: 'number',
name: 'typeChangedWidget',
value: 42,
options: { min: 0, max: 100 },
y: 0
},
node
)
}
describe('BaseWidget store integration', () => {
let graph: LGraph
let node: LGraphNode
@@ -175,6 +205,31 @@ describe('BaseWidget store integration', () => {
store.getWidget(widgetId(graph.id, toNodeId(1), 'valuesWidget'))?.value
).toBe(77)
})
it('registers the live widget type', () => {
const widget = createMutableTypeWidget(node)
widget.type = 'number-custom'
widget.setNodeId(toNodeId(1))
expect(
store.getWidget(widgetId(graph.id, toNodeId(1), 'typeChangedWidget'))
?.type
).toBe('number-custom')
})
it('stores explicit isDOMWidget false over component presence', () => {
const widget = createTestWidget(node, { name: 'flaggedDomWidget' })
Object.assign(widget, { component: {}, isDOMWidget: false })
widget.setNodeId(toNodeId(1))
expect(
store.getWidgetRenderState(
widgetId(graph.id, toNodeId(1), 'flaggedDomWidget')
)?.isDOMWidget
).toBe(false)
})
})
describe('DOM widget value registration', () => {

View File

@@ -17,6 +17,7 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { deriveWidgetRenderState } from '@/lib/litegraph/src/utils/widget'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
@@ -147,12 +148,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
const graphId = this.node.graph?.rootGraph.id
if (!graphId) return
this._state = useWidgetValueStore().registerWidget(
widgetId(graphId, nodeId, this.name),
const store = useWidgetValueStore()
const id = widgetId(graphId, nodeId, this.name)
this._state = store.registerWidget(
id,
{
...this._state,
type: this.type,
value: this.value
}
},
deriveWidgetRenderState(this)
)
}

View File

@@ -54,7 +54,7 @@ export class LinkConnectorAdapter {
const fromReroute = this.network.getReroute(opts?.fromRerouteId)
if (opts?.moveExisting) {
this.linkConnector.moveOutputLink(this.network, output)
this.linkConnector.moveOutputLink(this.network, node, output)
} else {
this.linkConnector.dragNewFromOutput(
this.network,
@@ -90,7 +90,9 @@ export class LinkConnectorAdapter {
const startPoint: Point | undefined = opts.layout
? [opts.layout.position.x, opts.layout.position.y]
: undefined
this.linkConnector.moveInputLink(this.network, input, { startPoint })
this.linkConnector.moveInputLink(this.network, node, input, {
startPoint
})
} else {
this.linkConnector.dragNewFromInput(
this.network,

View File

@@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
@@ -11,6 +13,7 @@ const MISSING_NODE = toNodeId('999')
const NEW_NODE = toNodeId('99')
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
layoutStore.initializeFromLiteGraph([
{ id: NODE_1, pos: [10, 20], size: [200, 100] },
{ id: NODE_2, pos: [300, 400], size: [150, 80] }

View File

@@ -10,7 +10,6 @@ import type { NodeId } from '@/types/nodeId'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type {
LayoutSource,
LinkId,
NodeLayout,
Point,
RerouteId,
@@ -26,22 +25,9 @@ interface LayoutMutations {
setNodeZIndex(nodeId: NodeId, zIndex: number): void
createNode(nodeId: NodeId, layout: Partial<NodeLayout>): void
deleteNode(nodeId: NodeId): void
createLink(
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void
deleteLink(linkId: LinkId): void
// Reroute operations
createReroute(
rerouteId: RerouteId,
position: Point,
parentId?: RerouteId,
linkIds?: LinkId[]
): void
createReroute(rerouteId: RerouteId, position: Point): void
deleteReroute(rerouteId: RerouteId): void
moveReroute(
rerouteId: RerouteId,
@@ -217,72 +203,16 @@ export function useLayoutMutations(): LayoutMutations {
setNodeZIndex(nodeId, maxZIndex + 1)
}
/**
* Create a new link
*/
const createLink = (
linkId: LinkId,
sourceNodeId: NodeId,
sourceSlot: number,
targetNodeId: NodeId,
targetSlot: number
): void => {
logger.debug('Creating link:', {
linkId,
from: `${sourceNodeId}[${sourceSlot}]`,
to: `${targetNodeId}[${targetSlot}]`
})
layoutStore.applyOperation({
type: 'createLink',
entity: 'link',
linkId,
sourceNodeId,
sourceSlot,
targetNodeId,
targetSlot,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Delete a link
*/
const deleteLink = (linkId: LinkId): void => {
logger.debug('Deleting link:', linkId)
layoutStore.applyOperation({
type: 'deleteLink',
entity: 'link',
linkId,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
})
}
/**
* Create a new reroute
*/
const createReroute = (
rerouteId: RerouteId,
position: Point,
parentId?: RerouteId,
linkIds: LinkId[] = []
): void => {
logger.debug('Creating reroute:', {
rerouteId,
position,
parentId,
linkCount: linkIds.length
})
const createReroute = (rerouteId: RerouteId, position: Point): void => {
logger.debug('Creating reroute:', { rerouteId, position })
layoutStore.applyOperation({
type: 'createReroute',
entity: 'reroute',
rerouteId,
position,
parentId,
linkIds,
timestamp: Date.now(),
source: layoutStore.getCurrentSource(),
actor: layoutStore.getCurrentActor()
@@ -339,8 +269,6 @@ export function useLayoutMutations(): LayoutMutations {
createNode,
deleteNode,
bringNodeToFront,
createLink,
deleteLink,
createReroute,
deleteReroute,
moveReroute

View File

@@ -1,3 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { toLinkId } from '@/types/linkId'
@@ -29,6 +32,10 @@ function expectSingleOperation(
expect(operations[0]).toEqual(expect.objectContaining(expectedOperation))
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
// Clear the store before each test
@@ -771,7 +778,7 @@ describe('layoutStore getNodeLayoutRef setter', () => {
}
)
it('emits a deleteNode operation when setter receives null', () => {
it('ignores a null assignment; deletion goes through layoutMutations.deleteNode', () => {
const ref = layoutStore.getNodeLayoutRef(REF_NODE)
const layout = baseLayout()
ref.value = layout
@@ -780,12 +787,8 @@ describe('layoutStore getNodeLayoutRef setter', () => {
ref.value = null
})
expectSingleOperation(operations, {
type: 'deleteNode',
nodeId: REF_NODE,
previousLayout: layout
})
expect(ref.value).toBeNull()
expect(operations).toEqual([])
expect(ref.value).toEqual(layout)
})
})
@@ -852,7 +855,7 @@ describe('layoutStore link layout updates', () => {
layoutStore.initializeFromLiteGraph([])
})
const stubPath = () => ({}) as unknown as Path2D
const stubPath = () => fromPartial<Path2D>({})
const baseLink = (path = stubPath()) => ({
id: toLinkId(1),
path,

View File

@@ -10,7 +10,6 @@ import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { toLinkId } from '@/types/linkId'
import { toNodeId } from '@/types/nodeId'
import { toRerouteId } from '@/types/rerouteId'
@@ -19,10 +18,8 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import type {
BatchUpdateBoundsOperation,
Bounds,
CreateLinkOperation,
CreateNodeOperation,
CreateRerouteOperation,
DeleteLinkOperation,
DeleteNodeOperation,
DeleteRerouteOperation,
LayoutChange,
@@ -73,23 +70,9 @@ function asRerouteId(id: string | number): RerouteId {
return toRerouteId(Number(id))
}
function asLinkId(id: string | number): LinkId {
return toLinkId(Number(id))
}
interface LinkData {
id: LinkId
sourceNodeId: NodeId
targetNodeId: NodeId
sourceSlot: number
targetSlot: number
}
interface RerouteData {
id: RerouteId
position: Point
parentId: RerouteId
linkIds: LinkId[]
}
// Generic typed Y.Map interface
@@ -101,15 +84,12 @@ interface TypedYMap<T> {
class LayoutStoreImpl implements LayoutStore {
private static readonly REROUTE_DEFAULTS: RerouteData = {
id: toRerouteId(0),
position: { x: 0, y: 0 },
parentId: toRerouteId(0),
linkIds: []
position: { x: 0, y: 0 }
}
// Yjs document and shared data structures
private ydoc = new Y.Doc()
private ynodes: Y.Map<NodeLayoutMap> // Maps nodeId -> NodeLayoutMap containing NodeLayout data
private ylinks: Y.Map<Y.Map<unknown>> // Maps linkId -> Y.Map containing link data
private yreroutes: Y.Map<Y.Map<unknown>> // Maps rerouteId -> Y.Map containing reroute data
private yoperations: Y.Array<LayoutOperation> // Operation log
@@ -172,7 +152,6 @@ class LayoutStoreImpl implements LayoutStore {
constructor() {
// Initialize Yjs data structures
this.ynodes = this.ydoc.getMap('nodes')
this.ylinks = this.ydoc.getMap('links')
this.yreroutes = this.ydoc.getMap('reroutes')
this.yoperations = this.ydoc.getArray('operations')
@@ -195,14 +174,6 @@ class LayoutStoreImpl implements LayoutStore {
})
})
// Listen for link changes and update spatial indexes
this.ylinks.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
event.changes.keys.forEach((change, linkIdStr) => {
this.handleLinkChange(change, linkIdStr)
})
})
// Listen for reroute changes and update spatial indexes
this.yreroutes.observe((event: Y.YMapEvent<Y.Map<unknown>>) => {
this.version++
@@ -212,14 +183,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
private getLinkField<K extends keyof LinkData>(
ylink: Y.Map<unknown>,
field: K
): LinkData[K] | undefined {
const typedLink = ylink as TypedYMap<LinkData>
return typedLink.get(field)
}
private getRerouteField<K extends keyof RerouteData>(
yreroute: Y.Map<unknown>,
field: K,
@@ -249,80 +212,68 @@ class LayoutStoreImpl implements LayoutStore {
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
// Delete operation
const existing = this.ynodes.get(nodeKey)
if (existing) {
this.applyOperation({
type: 'deleteNode',
entity: 'node',
nodeId,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor,
previousLayout: yNodeToLayout(existing)
})
}
// No caller assigns null through this ref; deletion goes through
// layoutMutations.deleteNode, which carries a graphId.
if (newLayout === null) return
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
this.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout: newLayout,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
// Update operation - detect what changed
const existing = this.ynodes.get(nodeKey)
if (!existing) {
// Create operation
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'createNode',
type: 'moveNode',
entity: 'node',
nodeId,
layout: newLayout,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
} else {
const existingLayout = yNodeToLayout(existing)
// Check what properties changed
if (
existingLayout.position.x !== newLayout.position.x ||
existingLayout.position.y !== newLayout.position.y
) {
this.applyOperation({
type: 'moveNode',
entity: 'node',
nodeId,
position: newLayout.position,
previousPosition: existingLayout.position,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (
existingLayout.size.width !== newLayout.size.width ||
existingLayout.size.height !== newLayout.size.height
) {
this.applyOperation({
type: 'resizeNode',
entity: 'node',
nodeId,
size: newLayout.size,
previousSize: existingLayout.size,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
if (existingLayout.zIndex !== newLayout.zIndex) {
this.applyOperation({
type: 'setNodeZIndex',
entity: 'node',
nodeId,
zIndex: newLayout.zIndex,
previousZIndex: existingLayout.zIndex,
timestamp: Date.now(),
source: this.currentSource,
actor: this.currentActor
})
}
}
}
trigger()
@@ -862,12 +813,6 @@ class LayoutStoreImpl implements LayoutStore {
case 'batchUpdateBounds':
this.handleBatchUpdateBounds(operation, change)
break
case 'createLink':
this.handleCreateLink(operation, change)
break
case 'deleteLink':
this.handleDeleteLink(operation, change)
break
case 'createReroute':
this.handleCreateReroute(operation, change)
break
@@ -1123,18 +1068,8 @@ class LayoutStoreImpl implements LayoutStore {
// and cleanup is handled by onUnmounted in useSlotElementTracking.
// Remove from spatial index
this.spatialIndex.remove(nodeId)
// Clean up associated links
const linksToDelete = this.findLinksConnectedToNode(nodeId)
// Delete the associated links
for (const linkId of linksToDelete) {
const linkKey = String(linkId)
this.ylinks.delete(linkKey)
this.linkLayouts.delete(linkId)
// Clean up link segment layouts
this.cleanupLinkSegments(linkId)
}
// Link geometry is cleaned up per-link by LLink.disconnect as the node's
// connections are severed, so nothing to do here.
change.type = 'delete'
change.nodeIds.push(nodeId)
@@ -1172,40 +1107,6 @@ class LayoutStoreImpl implements LayoutStore {
}
}
private handleCreateLink(
operation: CreateLinkOperation,
change: LayoutChange
): void {
const linkData = new Y.Map<unknown>()
linkData.set('id', operation.linkId)
linkData.set('sourceNodeId', operation.sourceNodeId)
linkData.set('sourceSlot', operation.sourceSlot)
linkData.set('targetNodeId', operation.targetNodeId)
linkData.set('targetSlot', operation.targetSlot)
const linkKey = String(operation.linkId)
this.ylinks.set(linkKey, linkData)
// Link geometry will be computed separately when nodes move
// This just tracks that the link exists
change.type = 'create'
}
private handleDeleteLink(
operation: DeleteLinkOperation,
change: LayoutChange
): void {
const linkKey = String(operation.linkId)
if (!this.ylinks.has(linkKey)) return
this.ylinks.delete(linkKey)
this.linkLayouts.delete(operation.linkId)
// Clean up any segment layouts for this link
this.cleanupLinkSegments(operation.linkId)
change.type = 'delete'
}
private handleCreateReroute(
operation: CreateRerouteOperation,
change: LayoutChange
@@ -1213,8 +1114,6 @@ class LayoutStoreImpl implements LayoutStore {
const rerouteData = new Y.Map<unknown>()
rerouteData.set('id', operation.rerouteId)
rerouteData.set('position', operation.position)
rerouteData.set('parentId', operation.parentId)
rerouteData.set('linkIds', operation.linkIds)
const rerouteKey = String(operation.rerouteId)
this.yreroutes.set(rerouteKey, rerouteData)
@@ -1281,43 +1180,6 @@ class LayoutStoreImpl implements LayoutStore {
})
}
/**
* Find all links connected to a specific node
*/
private findLinksConnectedToNode(nodeId: NodeId): LinkId[] {
const connectedLinks: LinkId[] = []
this.ylinks.forEach((linkData: Y.Map<unknown>, linkIdStr: string) => {
const linkId = asLinkId(linkIdStr)
const sourceNodeId = this.getLinkField(linkData, 'sourceNodeId')
const targetNodeId = this.getLinkField(linkData, 'targetNodeId')
if (sourceNodeId === nodeId || targetNodeId === nodeId) {
connectedLinks.push(linkId)
}
})
return connectedLinks
}
/**
* Handle link change events
*/
private handleLinkChange(change: YEventChange, linkIdStr: string): void {
if (change.action === 'delete') {
const linkId = asLinkId(linkIdStr)
this.cleanupLinkData(linkId)
}
// Link was added or updated - geometry will be computed separately
// This just tracks that the link exists in CRDT
}
/**
* Clean up all data associated with a link
*/
private cleanupLinkData(linkId: LinkId): void {
this.linkLayouts.delete(linkId)
this.cleanupLinkSegments(linkId)
}
/**
* Clean up all segment layouts for a link
*/

View File

@@ -113,7 +113,6 @@ interface OperationMeta {
* Entity-specific base types for proper type discrimination
*/
type NodeOpBase = OperationMeta & { entity: 'node'; nodeId: NodeId }
type LinkOpBase = OperationMeta & { entity: 'link'; linkId: LinkId }
type RerouteOpBase = OperationMeta & {
entity: 'reroute'
rerouteId: RerouteId
@@ -130,8 +129,6 @@ type OperationType =
| 'deleteNode'
| 'setNodeVisibility'
| 'batchUpdateBounds'
| 'createLink'
| 'deleteLink'
| 'createReroute'
| 'deleteReroute'
| 'moveReroute'
@@ -198,32 +195,12 @@ export interface BatchUpdateBoundsOperation extends OperationMeta {
bounds: Record<NodeId, { bounds: Bounds; previousBounds: Bounds }>
}
/**
* Create link operation
*/
export interface CreateLinkOperation extends LinkOpBase {
type: 'createLink'
sourceNodeId: NodeId
sourceSlot: number
targetNodeId: NodeId
targetSlot: number
}
/**
* Delete link operation
*/
export interface DeleteLinkOperation extends LinkOpBase {
type: 'deleteLink'
}
/**
* Create reroute operation
*/
export interface CreateRerouteOperation extends RerouteOpBase {
type: 'createReroute'
position: Point
parentId?: RerouteId
linkIds: LinkId[]
}
/**
@@ -253,8 +230,6 @@ export type LayoutOperation =
| DeleteNodeOperation
| SetNodeVisibilityOperation
| BatchUpdateBoundsOperation
| CreateLinkOperation
| DeleteLinkOperation
| CreateRerouteOperation
| DeleteRerouteOperation
| MoveRerouteOperation

View File

@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
selected: false,
executing: false,
subgraphId,
widgets: [],
inputs: [],
outputs: [],
hasErrors: false,

View File

@@ -60,7 +60,7 @@ vi.mock(
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: { getNodeById: vi.fn() },
rootGraph: { id: 'graph-test', getNodeById: vi.fn() },
canvas: { setDirty: vi.fn() }
}
}))
@@ -161,7 +161,6 @@ const mockNodeData: VueNodeData = {
flags: {},
inputs: [],
outputs: [],
widgets: [],
selected: false,
executing: false
}
@@ -178,6 +177,7 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.resetAllMocks()
mockData.mockExecuting = false
mockData.mockLgraphNode = null
setActivePinia(pinia)
const canvasStore = useCanvasStore()
@@ -274,17 +274,16 @@ describe('LGraphNode', () => {
})
it('should hide advanced footer button while the node is collapsed', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
flags: { collapsed: true }
}
})
@@ -294,18 +293,17 @@ describe('LGraphNode', () => {
})
it('should show error-only footer for collapsed nodes with advanced widgets', () => {
mockData.mockLgraphNode = {
isSubgraphNode: () => false,
widgets: [
{ name: 'advancedWidget', type: 'number', options: { advanced: true } }
]
}
renderLGraphNode({
nodeData: {
...mockNodeData,
flags: { collapsed: true },
hasErrors: true,
widgets: [
{
name: 'advancedWidget',
type: 'number',
options: { advanced: true }
}
]
hasErrors: true
}
})

View File

@@ -60,7 +60,7 @@
cn(
'pointer-events-none absolute z-0 border-3 outline-none',
selectionShapeClass,
hasAnyError ? 'inset-[-7px]' : 'inset-[-3px]',
hasAnyError ? '-inset-1.75' : '-inset-0.75',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
@@ -101,10 +101,10 @@
multi
class="absolute right-0 translate-x-1/2"
/>
<NodeSlots :node-data="nodeData" unified />
<NodeSlots :node-data unified />
</template>
<NodeHeader
:node-data="nodeData"
:node-data
:collapsed="isCollapsed"
:price-badges="badges.pricing"
@collapse="handleCollapse"
@@ -124,7 +124,7 @@
/>
<template v-if="!isCollapsed && isRerouteNode">
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
</template>
<template v-else-if="!isCollapsed">
@@ -151,20 +151,16 @@
"
:data-testid="`node-body-${nodeData.id}`"
>
<NodeSlots :node-data="nodeData" />
<NodeSlots :node-data />
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<NodeWidgets v-if="hasRenderableWidgets" :node-data />
<div v-if="hasCustomContent" class="flex min-h-0 flex-1 flex-col">
<NodeContent
v-if="nodeMedia"
:node-data="nodeData"
:media="nodeMedia"
/>
<NodeContent v-if="nodeMedia" :node-data :media="nodeMedia" />
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:node-data
:media="preview"
/>
</div>
@@ -297,6 +293,10 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { isVideoOutput } from '@/utils/litegraphUtil'
import {
@@ -719,18 +719,30 @@ const { promotedPreviews } = usePromotedPreviews(lgraphNode)
useGLSLPreview(lgraphNode)
const widgetValueStore = useWidgetValueStore()
const widgetIds = computed(() => {
const graphId = app.rootGraph?.id
const bareNodeId = stripGraphPrefix(nodeData.id)
if (!graphId || !bareNodeId) return []
return widgetValueStore.getNodeWidgetIds(graphId, bareNodeId) ?? []
})
const hasRenderableWidgets = computed(() => widgetIds.value.length > 0)
const showAdvancedInputsButton = computed(() => {
const node = lgraphNode.value
if (!node) return false
if (isCollapsed.value) return false
// For subgraph nodes: check for unpromoted widgets
if (node instanceof SubgraphNode) {
return hasUnpromotedWidgets(node)
}
// For regular nodes: show button if there are advanced widgets and they're currently hidden
const hasAdvancedWidgets = nodeData.widgets?.some((w) => w.options?.advanced)
const hasAdvancedWidgets = widgetIds.value.some((id) => {
const renderState = widgetValueStore.getWidgetRenderState(id)
const widgetState = widgetValueStore.getWidget(id)
return renderState?.advanced ?? widgetState?.options?.advanced
})
const alwaysShowAdvanced = settingStore.get(
'Comfy.Node.AlwaysShowAdvancedWidgets'
)

View File

@@ -1,6 +1,9 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { computed } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
@@ -9,12 +12,20 @@ vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
const WidgetGridProbe = {
props: ['processedWidgets'],
setup(props: { processedWidgets?: WidgetGridItem[] }) {
const widgets = computed(() =>
(props.processedWidgets ?? []).map((widget) => ({
name: widget.simplified.name,
value: widget.simplified.value,
options: { values: widget.simplified.options?.values }
}))
)
return { widgets }
},
template:
'<div data-testid="node-data">{{ JSON.stringify({ widgets }) }}</div>'
}
interface ProbedWidget {
@@ -39,10 +50,11 @@ function renderedWidgets(
render(LGraphNodePreview, {
props: { nodeDef: def, ...props },
global: {
plugins: [createTestingPinia({ stubActions: false })],
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
WidgetGrid: WidgetGridProbe
}
}
})
@@ -77,6 +89,17 @@ describe('LGraphNodePreview', () => {
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
it('leads with an explicitly empty provided value', () => {
const widget = renderedComboWidget({ widgetValues: { ckpt_name: '' } })
expect(widget?.value).toBe('')
expect(widget?.options?.values).toEqual([
'',
'a.safetensors',
'b.safetensors'
])
})
it('uses the input default when defined and empty string otherwise', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({
@@ -92,4 +115,19 @@ describe('LGraphNodePreview', () => {
expect(widgets.find((w) => w.name === 'steps')?.value).toBe(20)
expect(widgets.find((w) => w.name === 'text')?.value).toBe('')
})
it('hides advanced widgets in previews', () => {
const widgets = renderedWidgets(
fromPartial<ComfyNodeDefV2>({
name: 'TestNode',
inputs: {
prompt: { type: 'STRING' },
sampler: { type: 'STRING', advanced: true }
},
outputs: []
})
)
expect(widgets.map((widget) => widget.name)).toEqual(['prompt'])
})
})

View File

@@ -19,9 +19,11 @@
>
<NodeSlots :node-data="nodeData" />
<NodeWidgets
v-if="nodeData.widgets?.length"
:node-data="nodeData"
<WidgetGrid
v-if="previewWidgets.length"
:processed-widgets="previewWidgets"
:node-type="nodeData.type"
:node-id="nodeData.id"
class="pointer-events-none"
/>
</div>
@@ -36,14 +38,17 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { RenderShape } from '@/lib/litegraph/src/litegraph'
import NodeHeader from '@/renderer/extensions/vueNodes/components/NodeHeader.vue'
import NodeSlots from '@/renderer/extensions/vueNodes/components/NodeSlots.vue'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import { getComponent } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useWidgetStore } from '@/stores/widgetStore'
import { toNodeId } from '@/types/nodeId'
import type { WidgetValue } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
const {
@@ -58,39 +63,9 @@ const {
const widgetStore = useWidgetStore()
// Convert nodeDef into VueNodeData
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: toNodeId('-1'),
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
.filter(([, input]) => !widgetStore.inputIsWidget(input))
.map(([name, input]) => ({
name,
type: input.type,
@@ -119,16 +94,44 @@ const nodeData = computed<VueNodeData>(() => {
id: toNodeId(`preview-${nodeDef.name}`),
title: nodeDef.display_name || nodeDef.name,
type: nodeDef.name,
mode: 0, // Normal mode
mode: 0,
selected: false,
executing: false,
widgets,
inputs,
outputs,
flags: {
collapsed: false
}
}
})
const previewWidgets = computed<WidgetGridItem[]>(() =>
Object.entries(nodeDef.inputs || {})
.filter(
([, input]) =>
widgetStore.inputIsWidget(input) && !input.hidden && !input.advanced
)
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
const leadValue = widgetValues?.[name]
const value = (leadValue ??
input.default ??
comboValues?.[0] ??
'') as WidgetValue
const type = input.widgetType || input.type
const values =
leadValue !== undefined && comboValues
? [leadValue, ...comboValues.filter((option) => option !== leadValue)]
: comboValues
return {
visible: true,
renderKey: `preview:${nodeDef.name}:${name}`,
vueComponent: getComponent(type) ?? WidgetLegacy,
simplified: { name, type, value, options: { values }, spec: input }
}
})
)
</script>

View File

@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
mode: 0,
selected: false,
executing: false,
widgets: [],
inputs: [],
outputs: [],
flags: { collapsed: false },

View File

@@ -38,7 +38,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
executing: false,
inputs: [],
outputs: [],
widgets: [],
flags: { collapsed: false },
...overrides
})

View File

@@ -3,20 +3,17 @@
import { createTestingPinia } from '@pinia/testing'
import { render } from '@testing-library/vue'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeExecutionId } from '@/types/nodeIdentification'
import { toNodeId } from '@/types/nodeId'
import type { NodeId } from '@/types/nodeId'
import { widgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
const GRAPH_ID = 'graph-test'
@@ -36,7 +33,13 @@ const WidgetStub = {
name: 'WidgetStub',
props: ['widget', 'nodeId', 'nodeType', 'modelValue'],
template:
'<div class="widget-stub" :data-node-type="nodeType">{{ nodeType }}</div>'
'<div class="widget-stub" :data-node-type="nodeType" :data-name="widget.name">{{ nodeType }}</div>'
}
const AppInputStub = {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
vi.mock(
@@ -50,63 +53,78 @@ vi.mock(
}
)
describe('NodeWidgets', () => {
const createMockWidget = (
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData => ({
nodeId: toNodeId('test_node'),
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
})
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = [],
id: NodeId = toNodeId(1)
): VueNodeData => ({
function createMockNodeData(
nodeType = 'TestNode',
id: NodeId = toNodeId(1)
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
})
function renderComponent(nodeData?: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true
},
mocks: {
$t: (key: string) => key
}
}
})
}
}
function registerWidgetState(
id: WidgetId,
init: {
type?: string
value?: unknown
options?: Record<string, unknown>
} = {}
) {
useWidgetValueStore().registerWidget(id, {
type: init.type ?? 'combo',
value: init.value ?? 'value',
options: init.options ?? {}
})
}
function renderComponent({
nodeData,
widgetIds,
setupStores
}: {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
setupStores?: () => void
}) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return render(NodeWidgets, {
props: {
nodeData,
widgetIds
},
global: {
plugins: [pinia],
stubs: {
InputSlot: true,
AppInput: AppInputStub
},
mocks: {
$t: (key: string) => key
}
}
})
}
describe('NodeWidgets', () => {
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('CheckpointLoaderSimple', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('CheckpointLoaderSimple')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -116,15 +134,31 @@ describe('NodeWidgets', () => {
})
it('renders no widgets when nodeData is undefined', () => {
const { container } = renderComponent(undefined)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const { container } = renderComponent({
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('renders no widgets when no widget ids are registered or passed', () => {
const { container } = renderComponent({
nodeData: createMockNodeData('CheckpointLoaderSimple')
})
expect(container.querySelectorAll('.widget-stub')).toHaveLength(0)
})
it('passes empty string when nodeData.type is empty', () => {
const widget = createMockWidget()
const nodeData = createMockNodeData('', [widget])
const { container } = renderComponent(nodeData)
const id = widgetId(GRAPH_ID, toNodeId(1), 'test_widget')
const nodeData = createMockNodeData('')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => registerWidgetState(id)
})
const stub = container.querySelector('.widget-stub')
expect(stub).not.toBeNull()
@@ -132,7 +166,18 @@ describe('NodeWidgets', () => {
})
})
it('deduplicates widgets with identical render identity while keeping distinct promoted sources', () => {
it('derives widget ids from the store when ids are not passed', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'test_widget')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
setupStores: () => registerWidgetState(id)
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('deduplicates repeated widget ids while keeping distinct widget ids', () => {
const duplicateEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
@@ -143,163 +188,34 @@ describe('NodeWidgets', () => {
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
'string_a'
)
const duplicateA = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const duplicateB = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: duplicateEntityId
})
const distinct = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:20'),
widgetId: distinctEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
duplicateA,
duplicateB,
distinct
])
const nodeData = createMockNodeData('SubgraphNode')
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('prefers a visible duplicate over a hidden duplicate when identities collide', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const hiddenDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: true }
})
const visibleDuplicate = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId,
options: { hidden: false }
})
const nodeData = createMockNodeData('SubgraphNode', [
hiddenDuplicate,
visibleDuplicate
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(1)
})
it('does not deduplicate entries that share names but have different widget types', () => {
const sharedEntityId = widgetId(
GRAPH_ID,
toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
'string_a'
)
const textWidget = createMockWidget({
name: 'string_a',
type: 'text',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const comboWidget = createMockWidget({
name: 'string_a',
type: 'combo',
nodeId: toNodeId('5e0670b8-ea2c-4fb6-8b73-a1100a2d4f8f:19'),
widgetId: sharedEntityId
})
const nodeData = createMockNodeData('SubgraphNode', [
textWidget,
comboWidget
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('keeps unresolved same-name promoted entries distinct by source execution identity', () => {
const firstTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(18)])
})
const secondTransientEntry = createMockWidget({
nodeId: undefined,
name: 'string_a',
type: 'text',
sourceExecutionId: createNodeExecutionId([toNodeId(65), toNodeId(19)])
})
const nodeData = createMockNodeData('SubgraphNode', [
firstTransientEntry,
secondTransientEntry
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:1'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:1'), 'text')
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: toNodeId('outer-subgraph:2'),
widgetId: widgetId(GRAPH_ID, toNodeId('outer-subgraph:2'), 'text')
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const { container } = renderComponent(nodeData)
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'test_widget',
options: { hidden: false }
})
])
const { container } = renderComponent(nodeData)
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(
widgetId('graph-test', toNodeId('test_node'), 'test_widget'),
{
type: 'combo',
value: 'value',
options: { hidden: true },
label: undefined,
serialize: true,
disabled: false
const { container } = renderComponent({
nodeData,
widgetIds: [duplicateEntityId, duplicateEntityId, distinctEntityId],
setupStores: () => {
registerWidgetState(duplicateEntityId, { type: 'text' })
registerWidgetState(distinctEntityId, { type: 'text' })
}
)
})
await nextTick()
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(2)
})
it('hides widgets when store options mark them hidden', () => {
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const id = widgetId(GRAPH_ID, toNodeId('test_node'), 'test_widget')
const { container } = renderComponent({
nodeData,
widgetIds: [id],
setupStores: () => {
registerWidgetState(id, {
type: 'combo',
options: { hidden: true }
})
}
})
expect(container.querySelectorAll('.lg-node-widget')).toHaveLength(0)
})
@@ -307,44 +223,17 @@ describe('NodeWidgets', () => {
it('forwards canonical widgetId to AppInput for selection', () => {
const seedAEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_a')
const seedBEntityId = widgetId(GRAPH_ID, toNodeId('test_node'), 'seed_b')
const nodeData = createMockNodeData('TestNode', [
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_a',
type: 'text',
widgetId: seedAEntityId
}),
createMockWidget({
nodeId: toNodeId('test_node'),
name: 'seed_b',
type: 'text',
widgetId: seedBEntityId
})
])
const nodeData = createMockNodeData('TestNode', toNodeId('test_node'))
const { container } = render(NodeWidgets, {
props: { nodeData },
global: {
plugins: [
(() => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
return pinia
})()
],
stubs: {
InputSlot: true,
AppInput: {
props: ['widgetId', 'name', 'enable'],
template:
'<div class="app-input-stub" :data-entity-id="widgetId"><slot /></div>'
}
},
mocks: {
$t: (key: string) => key
}
const { container } = renderComponent({
nodeData,
widgetIds: [seedAEntityId, seedBEntityId],
setupStores: () => {
registerWidgetState(seedAEntityId, { type: 'text' })
registerWidgetState(seedBEntityId, { type: 'text' })
}
})
const appInputElements = container.querySelectorAll('.app-input-stub')
const ids = Array.from(appInputElements).map((el) =>
el.getAttribute('data-entity-id')
@@ -352,4 +241,35 @@ describe('NodeWidgets', () => {
expect(ids).toStrictEqual([seedAEntityId, seedBEntityId])
})
it('marks widgets with host execution errors', () => {
const nodeId = toNodeId('test_node')
const id = widgetId(GRAPH_ID, nodeId, 'seed')
const { container } = renderComponent({
nodeData: createMockNodeData('TestNode', nodeId),
widgetIds: [id],
setupStores: () => {
useExecutionErrorStore().lastNodeErrors = {
[createNodeExecutionId([nodeId])]: {
errors: [
{
type: 'value_not_in_list',
message: 'seed is invalid',
details: '',
extra_info: { input_name: 'seed' }
}
],
class_type: 'TestNode',
dependent_outputs: []
}
}
registerWidgetState(id, { type: 'text' })
}
})
expect(container.querySelector('.widget-stub')?.className).toContain(
'text-node-stroke-error'
)
})
})

View File

@@ -2,104 +2,43 @@
<div v-if="renderError" class="node-error p-2 text-sm text-red-500">
{{ st('nodeErrors.widgets', 'Node Widgets Error') }}
</div>
<div
<WidgetGrid
v-else
data-testid="node-widgets"
:processed-widgets
:node-type
:can-select-inputs
:node-id="nodeData?.id"
:class="
cn(
'lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1',
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
)
shouldHandleNodePointerEvents
? 'pointer-events-auto'
: 'pointer-events-none'
"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
@pointerdown.capture="handleBringToFront"
@pointerdown="handleWidgetPointerEvent"
@pointermove="handleWidgetPointerEvent"
@pointerup="handleWidgetPointerEvent"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch pr-3"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id="nodeData?.id"
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-model="widget.value"
v-tooltip.left="widget.tooltipConfig"
:widget="widget.simplified"
:node-id="nodeData?.id"
:node-type="nodeType"
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
/>
</template>
<script setup lang="ts">
import { onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import type { WidgetId } from '@/types/widgetId'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { st } from '@/i18n'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import WidgetGrid from '@/renderer/extensions/vueNodes/components/WidgetGrid.vue'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
import { useProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps {
nodeData?: VueNodeData
widgetIds?: readonly WidgetId[]
}
const { nodeData } = defineProps<NodeWidgetsProps>()
const { nodeData, widgetIds } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions()
@@ -128,8 +67,10 @@ onErrorCaptured((error) => {
return false
})
const { canSelectInputs, gridTemplateRows, nodeType, processedWidgets } =
useProcessedWidgets(() => nodeData)
const { canSelectInputs, nodeType, processedWidgets } = useProcessedWidgets(
() => nodeData,
() => widgetIds
)
// Tracks widget-row growth that the node-level RO can't see
if (nodeData?.id != null) {

View File

@@ -0,0 +1,104 @@
<template>
<div
data-testid="node-widgets"
class="lg-node-widgets grid grid-cols-[min-content_minmax(80px,min-content)_minmax(125px,1fr)] gap-y-1 pr-3"
:style="{
'grid-template-rows': gridTemplateRows,
flex: gridTemplateRows.includes('auto') ? 1 : undefined
}"
>
<template v-for="widget in processedWidgets" :key="widget.renderKey">
<div
v-if="widget.visible"
data-testid="node-widget"
class="lg-node-widget group col-span-full grid grid-cols-subgrid items-stretch"
>
<!-- Widget Input Slot Dot -->
<div
:class="
cn(
'z-10 flex w-3 items-stretch opacity-0 transition-opacity duration-150 group-hover:opacity-100',
widget.slotMetadata?.linked && 'opacity-100'
)
"
>
<InputSlot
v-if="widget.slotMetadata"
:key="`widget-slot-${widget.simplified.name}-${widget.slotMetadata.index}`"
:slot-data="{
name: widget.simplified.name,
type: widget.slotMetadata.type,
boundingRect: [0, 0, 0, 0]
}"
:node-id
:has-error="widget.hasError"
:index="widget.slotMetadata.index"
:socketless="widget.simplified.spec?.socketless"
dot-only
/>
</div>
<!-- Widget Component -->
<AppInput
:widget-id="widget.widgetId"
:name="widget.simplified.name"
:enable="canSelectInputs && !widget.simplified.options?.disabled"
>
<component
:is="widget.vueComponent"
v-tooltip.left="widget.tooltipConfig ?? EMPTY_TOOLTIP"
:model-value="widget.simplified.value"
:widget="widget.simplified"
:node-id
:node-type
:class="
cn(
'col-span-2',
widget.hasError && 'font-bold text-node-stroke-error'
)
"
@update:model-value="widget.updateHandler"
@contextmenu="widget.handleContextMenu"
/>
</AppInput>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import AppInput from '@/renderer/extensions/linearMode/AppInput.vue'
import type { WidgetGridItem } from '@/renderer/extensions/vueNodes/types/widgetGrid'
import { shouldExpand } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { NodeId } from '@/types/nodeId'
import { cn } from '@comfyorg/tailwind-utils'
import InputSlot from './InputSlot.vue'
const EMPTY_TOOLTIP: TooltipOptions = {}
const {
processedWidgets,
nodeType,
canSelectInputs = false,
nodeId
} = defineProps<{
processedWidgets: WidgetGridItem[]
nodeType: string
canSelectInputs?: boolean
nodeId?: NodeId
}>()
const gridTemplateRows = computed(() =>
processedWidgets
.filter((widget) => widget.visible)
.map((widget) =>
shouldExpand(widget.simplified.type) || widget.hasLayoutSize
? 'auto'
: 'min-content'
)
.join(' ')
)
</script>

View File

@@ -2,7 +2,6 @@ import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { i18n, te } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
@@ -19,9 +18,8 @@ const positiveCoordsTooltipKey =
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
const positiveCoordsWidget: SafeWidgetData = {
name: 'positive_coords',
type: 'STRING'
const positiveCoordsWidget: { name: string; tooltip?: string } = {
name: 'positive_coords'
}
function mergeOutputTooltipMessage(tooltip: string | null) {

View File

@@ -5,7 +5,6 @@ import type {
import { computed, ref, unref } from 'vue'
import type { MaybeRef } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import { st, stRaw } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
@@ -136,11 +135,11 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
/**
* Get tooltip text for widgets
*/
const getWidgetTooltip = (widget: SafeWidgetData) => {
const getWidgetTooltip = (widget: { name: string; tooltip?: string }) => {
if (!tooltipsEnabled.value || !nodeDef.value) return ''
// First try widget-specific tooltip
const widgetTooltip = (widget as { tooltip?: string }).tooltip
const widgetTooltip = widget.tooltip
if (widgetTooltip) return widgetTooltip
// Then try input-based tooltip lookup

View File

@@ -1,51 +1,61 @@
import type { TooltipOptions } from 'primevue'
import { computed } from 'vue'
import type { Component } from 'vue'
import type {
SafeWidgetData,
VueNodeData,
WidgetSlotMetadata
} from '@/composables/graph/useGraphNodeManager'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useAppMode } from '@/composables/useAppMode'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import type {
WidgetGridItem,
WidgetSlotMetadata
} from '@/renderer/extensions/vueNodes/types/widgetGrid'
import WidgetDOM from '@/renderer/extensions/vueNodes/widgets/components/WidgetDOM.vue'
import WidgetLegacy from '@/renderer/extensions/vueNodes/widgets/components/WidgetLegacy.vue'
import {
getComponent,
shouldExpand,
shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import { app } from '@/scripts/app'
import { nodeTypeValidForApp } from '@/stores/appModeStore'
import { useLinkStore } from '@/stores/linkStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
stripGraphPrefix,
useWidgetValueStore
} from '@/stores/widgetValueStore'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import {
createNodeExecutionId,
createNodeLocatorId
} from '@/types/nodeIdentification'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import type { NodeId } from '@/types/nodeId'
import type { WidgetId } from '@/types/widgetId'
import { widgetId } from '@/types/widgetId'
import type { WidgetState } from '@/types/widgetState'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { getControlWidget } from '@/types/simplifiedWidget'
import type {
LinkedUpstreamInfo,
SafeControlWidget,
SimplifiedWidget,
WidgetValue
} from '@/types/simplifiedWidget'
import { getExecutionIdFromNodeData } from '@/utils/graphTraversalUtil'
import type { WidgetId } from '@/types/widgetId'
import {
getExecutionIdFromNodeData,
getLocatorIdFromNodeData,
getNodeByLocatorId
} from '@/utils/graphTraversalUtil'
import { mapLiveWidgetsById } from '@/utils/litegraphUtil'
const TOOLTIP_VALUE_TYPES = ['asset', 'combo', 'number', 'text'] as const
type TooltipValueType = (typeof TOOLTIP_VALUE_TYPES)[number]
@@ -53,33 +63,36 @@ function isTooltipValueType(val: unknown): val is TooltipValueType {
return TOOLTIP_VALUE_TYPES.includes(val as TooltipValueType)
}
interface ProcessedWidget {
advanced: boolean
interface WidgetTooltipSource {
name: string
tooltip?: string
}
interface WidgetErrorTarget {
executionId: NodeExecutionId
widgetName: string
}
export interface ProcessedWidget extends WidgetGridItem {
handleContextMenu: (e: PointerEvent) => void
hasLayoutSize: boolean
hasError: boolean
hidden: boolean
id?: string
widgetId?: WidgetId
name: string
renderKey: string
simplified: SimplifiedWidget
widgetId: WidgetId
tooltipConfig: TooltipOptions
type: string
updateHandler: (value: WidgetValue) => void
value: WidgetValue
visible: boolean
vueComponent: Component
slotMetadata?: WidgetSlotMetadata
}
interface WidgetUiCallbacks {
getTooltipConfig: (widget: SafeWidgetData, fullVal?: string) => TooltipOptions
getTooltipConfig: (
widget: WidgetTooltipSource,
fullVal?: string
) => TooltipOptions
handleNodeRightClick: (e: PointerEvent, nodeId: NodeId) => void
}
interface ComputeProcessedWidgetsOptions {
nodeData: VueNodeData | undefined
widgetIds?: readonly WidgetId[]
graphId: string | undefined
showAdvanced: boolean
isGraphReady: boolean
@@ -87,86 +100,53 @@ interface ComputeProcessedWidgetsOptions {
ui: WidgetUiCallbacks
}
function createWidgetUpdateHandler(
widgetState: WidgetState | undefined,
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
widgetOptions: IWidgetOptions | Record<string, never>,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
if (widgetState) widgetState.value = newValue
widget.callback?.(newValue)
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (widget.sourceExecutionId) {
const sourceWidgetName = widget.sourceWidgetName ?? widget.name
executionErrorStore.clearWidgetRelatedErrors(
widget.sourceExecutionId,
sourceWidgetName,
sourceWidgetName,
newValue,
options
)
function normalizeWidgetValue(value: unknown): WidgetValue {
if (value === undefined) {
return undefined
}
if (value === null) {
return null
}
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (typeof value === 'object') return value
console.warn(`Invalid widget value type: ${typeof value}`, value)
return undefined
}
function buildSlotMetadata(
inputs: INodeInputSlot[] | undefined,
graphRef: LGraph | null | undefined,
graphId: string | undefined,
nodeId: NodeId
): Map<string, WidgetSlotMetadata> {
const linkStore = useLinkStore()
const metadata = new Map<string, WidgetSlotMetadata>()
inputs?.forEach((input, index) => {
const link = graphId
? linkStore.getInputSlotLink(graphId, nodeId, index)
: undefined
const linked = link !== undefined
const originNode = link ? graphRef?.getNodeById(link.originNodeId) : null
const slotInfo: WidgetSlotMetadata = {
index,
linked,
originNodeId: link?.originNodeId,
originOutputName: link
? originNode?.outputs?.[link.originSlot]?.name
: undefined,
type: String(input.type)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widget.name,
widget.name,
newValue,
options
)
}
}
export function hasWidgetError(
widget: SafeWidgetData,
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const errors = widget.sourceExecutionId
? executionErrorStore.lastNodeErrors?.[widget.sourceExecutionId]?.errors
: nodeErrors?.errors
return (
!!errors?.some((e) => e.extra_info?.input_name === widget.name) ||
missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
)
}
export function getWidgetIdentity(
widget: SafeWidgetData,
nodeId: NodeId | undefined,
index: number
): {
dedupeIdentity?: string
renderKey: string
} {
if (widget.widgetId) {
const dedupeIdentity = `${widget.widgetId}:${widget.type}`
return { dedupeIdentity, renderKey: dedupeIdentity }
}
const hostNodeIdRoot = nodeId ? stripGraphPrefix(nodeId) : null
const widgetNodeIdRoot = widget.nodeId
? stripGraphPrefix(widget.nodeId)
: null
const stableIdentityRoot = widgetNodeIdRoot
? `node:${widgetNodeIdRoot}`
: widget.sourceExecutionId
? `exec:${widget.sourceExecutionId}`
: hostNodeIdRoot
? `node:${hostNodeIdRoot}`
: undefined
const dedupeIdentity = stableIdentityRoot
? `${stableIdentityRoot}:${widget.name}:${widget.type}`
: undefined
const renderKey =
dedupeIdentity ??
`transient:${String(nodeId ?? '')}:${widget.name}:${widget.type}:${index}`
return { dedupeIdentity, renderKey }
if (input.name) metadata.set(input.name, slotInfo)
if (input.widget?.name) metadata.set(input.widget.name, slotInfo)
})
return metadata
}
function getProcessedNodeExecutionId(
@@ -190,7 +170,16 @@ function getWidgetNodeLocatorId(
)
}
export function isWidgetVisible(
function getHostNode(
rootGraph: LGraph | null,
nodeData: VueNodeData
): LGraphNode | null {
if (!rootGraph) return null
const locatorId = getLocatorIdFromNodeData(nodeData)
return locatorId ? getNodeByLocatorId(rootGraph, locatorId) : null
}
function isWidgetVisible(
options: IWidgetOptions,
showAdvanced: boolean,
linked = false
@@ -200,19 +189,266 @@ export function isWidgetVisible(
return !hidden && (!advanced || showAdvanced || linked)
}
function hasWidgetError(
widget: { name: string; errorTarget?: WidgetErrorTarget },
nodeExecId: NodeExecutionId,
nodeErrors:
| { errors: { extra_info?: { input_name?: string } }[] }
| undefined,
executionErrorStore: ReturnType<typeof useExecutionErrorStore>,
missingModelStore: ReturnType<typeof useMissingModelStore>
): boolean {
const hasHostError =
!!nodeErrors?.errors.some(
(e) => e.extra_info?.input_name === widget.name
) || missingModelStore.isWidgetMissingModel(nodeExecId, widget.name)
const target = widget.errorTarget
if (!target) return hasHostError
const sourceErrors = executionErrorStore.lastNodeErrors?.[target.executionId]
return (
hasHostError ||
!!sourceErrors?.errors.some(
(e) => e.extra_info?.input_name === target.widgetName
) ||
missingModelStore.isWidgetMissingModel(
target.executionId,
target.widgetName
)
)
}
function createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId,
widgetName,
widgetOptions,
executionErrorStore,
widgetValueStore
}: {
id: WidgetId
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
nodeExecId: NodeExecutionId
widgetName: string
widgetOptions: IWidgetOptions
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
widgetValueStore: ReturnType<typeof useWidgetValueStore>
}): (newValue: WidgetValue) => void {
return (newValue: WidgetValue) => {
widgetValueStore.setValue(id, newValue)
if (live) {
const normalized = normalizeWidgetValue(newValue)
live.widget.value = normalized ?? undefined
live.widget.callback?.(normalized, app.canvas, live.node)
live.node.widgets?.forEach((w) => w.triggerDraw?.())
}
const options = { min: widgetOptions?.min, max: widgetOptions?.max }
if (errorTarget) {
executionErrorStore.clearWidgetRelatedErrors(
errorTarget.executionId,
errorTarget.widgetName,
errorTarget.widgetName,
newValue,
options
)
}
executionErrorStore.clearWidgetRelatedErrors(
nodeExecId,
widgetName,
widgetName,
newValue,
options
)
}
}
function resolveWidgetIds(
graphId: string | undefined,
nodeId: NodeId,
explicitWidgetIds: readonly WidgetId[] | undefined,
widgetValueStore: ReturnType<typeof useWidgetValueStore>
): readonly WidgetId[] {
if (explicitWidgetIds) return explicitWidgetIds
const bareNodeId = stripGraphPrefix(nodeId)
return graphId && bareNodeId
? widgetValueStore.getNodeWidgetIds(graphId, bareNodeId)
: []
}
interface LiveWidgetContext {
live?: { node: LGraphNode; widget: IBaseWidget }
errorTarget?: WidgetErrorTarget
controlWidget?: SafeControlWidget
}
/**
* Resolves the live litegraph widget (and, for promoted subgraph inputs, its
* interior source) into the control widget and error target the render path
* needs. Empty when the widget has no live counterpart (e.g. static previews).
*/
function resolveLiveWidgetContext(
rootGraph: LGraph | null,
hostNode: LGraphNode | null,
liveWidget: IBaseWidget | undefined
): LiveWidgetContext {
if (!hostNode || !liveWidget) return {}
const promotedSource = resolvePromotedWidgetSource(
rootGraph,
hostNode,
liveWidget
)
const errorTarget: WidgetErrorTarget | undefined =
promotedSource?.sourceExecutionId
? {
executionId: promotedSource.sourceExecutionId,
widgetName: promotedSource.sourceWidgetName
}
: undefined
const controlWidget =
getControlWidget(liveWidget) ??
(promotedSource?.sourceWidget
? getControlWidget(promotedSource.sourceWidget)
: undefined)
return {
live: { node: hostNode, widget: liveWidget },
errorTarget,
controlWidget
}
}
interface WidgetProcessingContext {
nodeData: VueNodeData
showAdvanced: boolean
rootGraph: LGraph | null
hostNode: LGraphNode | null
liveWidgets: Map<WidgetId, IBaseWidget>
slotMetadata: Map<string, WidgetSlotMetadata>
nodeExecId: NodeExecutionId
nodeErrors: Parameters<typeof hasWidgetError>[2]
widgetValueStore: ReturnType<typeof useWidgetValueStore>
executionErrorStore: ReturnType<typeof useExecutionErrorStore>
missingModelStore: ReturnType<typeof useMissingModelStore>
nodeDefStore: ReturnType<typeof useNodeDefStore>
ui: WidgetUiCallbacks
}
function processWidget(
id: WidgetId,
ctx: WidgetProcessingContext
): ProcessedWidget | null {
const widgetState = ctx.widgetValueStore.getWidget(id)
if (!widgetState) return null
const renderState = ctx.widgetValueStore.getWidgetRenderState(id)
const options: IWidgetOptions = { ...(widgetState.options ?? {}) }
if (options.advanced === undefined) options.advanced = renderState?.advanced
if (!shouldRenderAsVue({ type: widgetState.type, options })) return null
const { live, errorTarget, controlWidget } = resolveLiveWidgetContext(
ctx.rootGraph,
ctx.hostNode,
ctx.liveWidgets.get(id)
)
const slotInfo = ctx.slotMetadata.get(widgetState.name)
const visible = isWidgetVisible(options, ctx.showAdvanced, slotInfo?.linked)
const isDisabled = slotInfo?.linked || widgetState.disabled
const widgetOptions = isDisabled ? { ...options, disabled: true } : options
const value = widgetState.value as WidgetValue
const bareWidgetId = stripGraphPrefix(widgetState.nodeId)
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotInfo?.linked && slotInfo.originNodeId
? { nodeId: slotInfo.originNodeId, outputName: slotInfo.originOutputName }
: undefined
const updateHandler = createWidgetUpdateHandler({
id,
live,
errorTarget,
nodeExecId: ctx.nodeExecId,
widgetName: widgetState.name,
widgetOptions,
executionErrorStore: ctx.executionErrorStore,
widgetValueStore: ctx.widgetValueStore
})
const simplified: SimplifiedWidget = {
name: widgetState.name,
type: widgetState.type,
value,
borderStyle: widgetOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined,
callback: updateHandler,
controlWidget,
label: widgetState.label,
linkedUpstream,
nodeLocatorId: getWidgetNodeLocatorId(ctx.nodeData, bareWidgetId),
options: widgetOptions,
spec: live
? ctx.nodeDefStore.getInputSpecForWidget(live.node, live.widget.name)
: undefined
}
const valueTooltip =
isTooltipValueType(widgetState.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ctx.ui.getTooltipConfig(
{ name: widgetState.name, tooltip: renderState?.tooltip },
valueTooltip
)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
ctx.ui.handleNodeRightClick(e, ctx.nodeData.id)
showNodeOptions(e, widgetState.name)
}
return {
handleContextMenu,
hasLayoutSize: renderState?.hasLayoutSize ?? false,
hasError: hasWidgetError(
{ name: widgetState.name, errorTarget },
ctx.nodeExecId,
ctx.nodeErrors,
ctx.executionErrorStore,
ctx.missingModelStore
),
widgetId: id,
renderKey: `${id}:${widgetState.type}`,
vueComponent:
getComponent(widgetState.type) ||
(renderState?.isDOMWidget ? WidgetDOM : WidgetLegacy),
simplified,
visible,
updateHandler,
tooltipConfig,
slotMetadata: slotInfo
}
}
export function computeProcessedWidgets({
nodeData,
widgetIds,
graphId,
showAdvanced,
isGraphReady,
rootGraph,
ui
}: ComputeProcessedWidgetsOptions): ProcessedWidget[] {
if (!nodeData?.widgets) return []
if (!nodeData) return []
const executionErrorStore = useExecutionErrorStore()
const missingModelStore = useMissingModelStore()
const widgetValueStore = useWidgetValueStore()
const nodeDefStore = useNodeDefStore()
const nodeExecId = getProcessedNodeExecutionId(
isGraphReady,
@@ -221,185 +457,51 @@ export function computeProcessedWidgets({
)
if (!nodeExecId) return []
const nodeErrors = executionErrorStore.lastNodeErrors?.[nodeExecId]
const nodeId = nodeData.id
const { widgets } = nodeData
const result: ProcessedWidget[] = []
const uniqueWidgets: Array<{
widget: SafeWidgetData
identity: ReturnType<typeof getWidgetIdentity>
mergedOptions: IWidgetOptions
widgetState: WidgetState | undefined
isVisible: boolean
}> = []
const dedupeIndexByIdentity = new Map<string, number>()
for (const [index, widget] of widgets.entries()) {
if (!shouldRenderAsVue(widget)) continue
const identity = getWidgetIdentity(widget, nodeId, index)
const widgetNodeId = stripGraphPrefix(widget.nodeId ?? nodeId)
const widgetState = widget.widgetId
? widgetValueStore.getWidget(widget.widgetId)
: graphId && widgetNodeId
? widgetValueStore.getWidget(
widgetId(graphId, widgetNodeId, widget.name)
)
: undefined
const mergedOptions: IWidgetOptions = {
...(widget.options ?? {}),
...(widgetState?.options ?? {})
}
const visible = isWidgetVisible(
mergedOptions,
showAdvanced,
widget.slotMetadata?.linked
)
if (!identity.dedupeIdentity) {
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingIndex = dedupeIndexByIdentity.get(identity.dedupeIdentity)
if (existingIndex === undefined) {
dedupeIndexByIdentity.set(identity.dedupeIdentity, uniqueWidgets.length)
uniqueWidgets.push({
widget,
identity,
mergedOptions,
widgetState,
isVisible: visible
})
continue
}
const existingWidget = uniqueWidgets[existingIndex]
if (existingWidget && !existingWidget.isVisible && visible) {
uniqueWidgets[existingIndex] = {
widget,
identity,
mergedOptions,
widgetState,
isVisible: true
}
}
const hostNode = getHostNode(rootGraph, nodeData)
const liveWidgets = hostNode
? mapLiveWidgetsById(hostNode)
: new Map<WidgetId, IBaseWidget>()
const orderedIds = resolveWidgetIds(
graphId,
nodeData.id,
widgetIds,
widgetValueStore
)
// Drop ids whose live widget is gone (e.g. removed directly on node.widgets);
// when the host node isn't resolvable yet, fall back to the stored order.
const ids = hostNode
? orderedIds.filter((id) => liveWidgets.has(id))
: orderedIds
const slotMetadata = buildSlotMetadata(
nodeData.inputs ?? hostNode?.inputs,
hostNode?.graph ?? rootGraph,
graphId,
nodeData.id
)
const ctx: WidgetProcessingContext = {
nodeData,
showAdvanced,
rootGraph,
hostNode,
liveWidgets,
slotMetadata,
nodeExecId,
nodeErrors: executionErrorStore.lastNodeErrors?.[nodeExecId],
widgetValueStore,
executionErrorStore,
missingModelStore,
nodeDefStore,
ui
}
for (const {
widget,
mergedOptions,
widgetState,
isVisible: visible,
identity: { renderKey }
} of uniqueWidgets) {
const bareWidgetId = stripGraphPrefix(widget.nodeId ?? nodeId)
const vueComponent =
getComponent(widget.type) ||
(widget.isDOMWidget ? WidgetDOM : WidgetLegacy)
const { slotMetadata } = widget
const value = widgetState?.value as WidgetValue
const isDisabled = slotMetadata?.linked || widgetState?.disabled
const widgetOptions = isDisabled
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle = mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId
? {
nodeId: slotMetadata.originNodeId,
outputName: slotMetadata.originOutputName
}
: undefined
const nodeLocatorId = getWidgetNodeLocatorId(nodeData, bareWidgetId)
const simplified: SimplifiedWidget = {
name: widgetState?.name ?? widget.name,
type: widget.type,
value,
borderStyle,
callback: widget.callback,
controlWidget: widget.controlWidget,
label: widgetState?.label,
linkedUpstream,
nodeLocatorId,
options: widgetOptions,
spec: widget.spec
}
const updateHandler = createWidgetUpdateHandler(
widgetState,
widget,
nodeExecId,
widgetOptions,
executionErrorStore
)
const valueTooltip =
isTooltipValueType(widget.type) && String(value).length > 10
? String(value)
: undefined
const tooltipConfig = ui.getTooltipConfig(widget, valueTooltip)
const handleContextMenu = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
if (nodeId !== undefined) ui.handleNodeRightClick(e, nodeId)
showNodeOptions(
e,
widget.name,
widget.nodeId !== undefined
? (stripGraphPrefix(widget.nodeId) ?? undefined)
: undefined
)
}
result.push({
advanced: mergedOptions.advanced ?? false,
handleContextMenu,
hasLayoutSize: widget.hasLayoutSize ?? false,
hasError: hasWidgetError(
widget,
nodeExecId,
nodeErrors,
executionErrorStore,
missingModelStore
),
hidden: mergedOptions.hidden ?? false,
widgetId: widget.widgetId,
name: widget.name,
renderKey,
type: widget.type,
vueComponent,
simplified,
value,
visible,
updateHandler,
tooltipConfig,
slotMetadata,
...(bareWidgetId === null ? {} : { id: bareWidgetId })
})
}
return result
return Array.from(new Set(ids))
.map((id) => processWidget(id, ctx))
.filter((widget): widget is ProcessedWidget => widget !== null)
}
export function useProcessedWidgets(
nodeDataGetter: () => VueNodeData | undefined
nodeDataGetter: () => VueNodeData | undefined,
widgetIdsGetter: () => readonly WidgetId[] | undefined = () => undefined
) {
const canvasStore = useCanvasStore()
const settingStore = useSettingStore()
@@ -436,6 +538,7 @@ export function useProcessedWidgets(
const processedWidgets = computed((): ProcessedWidget[] =>
computeProcessedWidgets({
nodeData: nodeDataGetter(),
widgetIds: widgetIdsGetter(),
graphId: canvasStore.canvas?.graph?.rootGraph.id,
showAdvanced: showAdvanced.value,
isGraphReady: app.isGraphReady,
@@ -444,23 +547,9 @@ export function useProcessedWidgets(
})
)
const visibleWidgets = computed(() =>
processedWidgets.value.filter((w) => w.visible)
)
const gridTemplateRows = computed((): string =>
visibleWidgets.value
.map((w) =>
shouldExpand(w.type) || w.hasLayoutSize ? 'auto' : 'min-content'
)
.join(' ')
)
return {
canSelectInputs,
gridTemplateRows,
nodeType,
processedWidgets,
visibleWidgets
processedWidgets
}
}

View File

@@ -62,7 +62,7 @@ vi.mock('@/scripts/app', () => ({
getNodeById: (id: string) => ({
id,
inputs: [],
outputs: [{ name: 'out', type: '*', links: [], _floatingLinks: null }]
outputs: [{ name: 'out', type: '*', links: [] }]
}),
getLink: () => null,
getReroute: () => null
@@ -186,7 +186,8 @@ vi.mock('@vueuse/core', () => ({
}))
vi.mock('@/lib/litegraph/src/LLink', () => ({
LLink: { getReroutes: () => [] }
LLink: { getReroutes: () => [] },
slotFloatingLinks: () => []
}))
vi.mock('@/lib/litegraph/src/types/globalEnums', () => ({

View File

@@ -5,7 +5,7 @@ import { useSharedCanvasPositionConversion } from '@/composables/element/useCanv
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LLink } from '@/lib/litegraph/src/LLink'
import { LLink, slotFloatingLinks } from '@/lib/litegraph/src/LLink'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
import type {
@@ -230,6 +230,8 @@ export function useSlotLinkInteraction({
const resolveExistingInputLinkAnchor = (
graph: LGraph,
nodeId: NodeId,
slotIndex: number,
inputSlot: INodeInputSlot | undefined
): { position: Point; direction: LinkDirection } | null => {
if (!inputSlot) return null
@@ -260,10 +262,7 @@ export function useSlotLinkInteraction({
if (directAnchor) return directAnchor
}
const floatingLinkIterator = inputSlot._floatingLinks?.values()
const floatingLink = floatingLinkIterator
? floatingLinkIterator.next().value
: undefined
const [floatingLink] = slotFloatingLinks(graph, 'input', nodeId, slotIndex)
if (!floatingLink) return null
if (floatingLink.parentId != null) {
@@ -631,11 +630,15 @@ export function useSlotLinkInteraction({
const ctrlOrMeta = event.ctrlKey || event.metaKey
const inputLinkId = inputSlot?.link ?? null
const inputFloatingCount = inputSlot?._floatingLinks?.size ?? 0
const inputFloatingCount = isInputSlot
? slotFloatingLinks(graph, 'input', localNodeId, index).length
: 0
const hasExistingInputLink = inputLinkId != null || inputFloatingCount > 0
const outputLinkCount = outputSlot?.links?.length ?? 0
const outputFloatingCount = outputSlot?._floatingLinks?.size ?? 0
const outputFloatingCount = isOutputSlot
? slotFloatingLinks(graph, 'output', localNodeId, index).length
: 0
const hasExistingOutputLink = outputLinkCount > 0 || outputFloatingCount > 0
const shouldBreakExistingInputLink =
@@ -675,7 +678,7 @@ export function useSlotLinkInteraction({
const existingAnchor =
isInputSlot && !shouldBreakExistingInputLink
? resolveExistingInputLinkAnchor(graph, inputSlot)
? resolveExistingInputLinkAnchor(graph, localNodeId, index, inputSlot)
: null
const shouldMoveExistingOutput =

View File

@@ -1,4 +1,4 @@
import { fromAny } from '@total-typescript/shoehorn'
import { fromAny, fromPartial } from '@total-typescript/shoehorn'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraph, LGraphExtra } from '@/lib/litegraph/src/LGraph'
@@ -10,6 +10,8 @@ vi.mock('@/scripts/app', () => ({
}))
import { ensureCorrectLayoutScale } from './ensureCorrectLayoutScale'
import { toRerouteId } from '@/types'
import type { Reroute } from '@/lib/litegraph/src/Reroute'
function createNode(id: string, x: number, y: number, w: number, h: number) {
return {
@@ -193,8 +195,13 @@ describe('ensureCorrectLayoutScale (legacy normalizer)', () => {
workflowRendererVersion: 'Vue'
})
const reroute = { id: 1, pos: [200, 200] as Point, linkIds: new Set([1]) }
;(graph.reroutes as Map<number, typeof reroute>).set(1, reroute)
const reroute = fromPartial<Reroute>({
id: 1,
pos: [200, 200] as Point,
linkIds: new Set([1])
})
if (!graph.reroutes) throw new Error('reroutes is undefined')
graph.reroutes.set(toRerouteId(1), reroute)
ensureCorrectLayoutScale(undefined, graph as LGraph)

View File

@@ -0,0 +1,33 @@
import type { TooltipOptions } from 'primevue'
import type { Component } from 'vue'
import type { NodeId } from '@/types/nodeId'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
import type { WidgetId } from '@/types/widgetId'
export interface WidgetSlotMetadata {
index: number
linked: boolean
originNodeId?: NodeId
originOutputName?: string
type: string
}
/**
* Data a widget row needs to render in {@link WidgetGrid}. Required fields cover
* the static preview path; the optional interactive fields are supplied only by
* the store-backed {@link ProcessedWidget} superset.
*/
export interface WidgetGridItem {
simplified: SimplifiedWidget
vueComponent: Component
visible: boolean
renderKey: string
hasLayoutSize?: boolean
hasError?: boolean
widgetId?: WidgetId
slotMetadata?: WidgetSlotMetadata
tooltipConfig?: TooltipOptions
updateHandler?: (value: WidgetValue) => void
handleContextMenu?: (e: PointerEvent) => void
}

View File

@@ -78,7 +78,9 @@ watch(() => canvasStore.currentGraph, bindWidget)
function draw() {
if (!widgetInstance || !node) return
const width = canvasEl.value.parentElement.clientWidth
const width =
canvasEl.value.parentElement.clientWidth ||
canvasEl.value.getBoundingClientRect().width
// Priority: computedHeight (from litegraph) > computeLayoutSize > computeSize
let height = 20
if (widgetInstance.computedHeight) {
@@ -126,7 +128,7 @@ function handleMove(e: PointerEvent) {
</script>
<template>
<div
class="relative mx-[-12px] min-w-0 basis-0"
class="relative mx-[-12px] w-full min-w-0"
:style="{ minHeight: `${containerHeight}px` }"
>
<canvas

View File

@@ -8,7 +8,6 @@ import {
shouldRenderAsVue,
FOR_TESTING
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
const {
WidgetButton,
@@ -134,7 +133,7 @@ describe('widgetRegistry', () => {
})
it('should respect options while checking type', () => {
const widget: Partial<SafeWidgetData> = {
const widget: { type: string; options: { canvasOnly: boolean } } = {
type: 'text',
options: { canvasOnly: false }
}

View File

@@ -4,7 +4,7 @@
import { defineAsyncComponent } from 'vue'
import type { Component } from 'vue'
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
const WidgetButton = defineAsyncComponent(
() => import('../components/WidgetButton.vue')
@@ -268,7 +268,10 @@ export const isEssential = (type: string): boolean => {
return widgets.get(canonicalType)?.essential || false
}
export const shouldRenderAsVue = (widget: Partial<SafeWidgetData>): boolean => {
export const shouldRenderAsVue = (widget: {
options?: Pick<IWidgetOptions, 'canvasOnly'>
type?: string
}): boolean => {
return !widget.options?.canvasOnly && !!widget.type
}

View File

@@ -0,0 +1,157 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { toLinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import { toNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
import { useLinkStore } from './linkStore'
const graphA: UUID = 'graph-a'
const graphB: UUID = 'graph-b'
function link(
id: number,
originNode: number,
originSlot: number,
targetNode: number,
targetSlot: number
): LinkTopology {
return {
id: toLinkId(id),
originNodeId: toNodeId(originNode),
originSlot,
targetNodeId: toNodeId(targetNode),
targetSlot,
type: 'INT'
}
}
describe('useLinkStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('answers input-slot connectedness with one lookup', () => {
const store = useLinkStore()
expect(store.registerLink(graphA, link(1, 5, 0, 9, 2))).toBeDefined()
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 3)).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('re-keys the link when its target moves', () => {
const store = useLinkStore()
const topology = link(1, 5, 0, 9, 2)
store.registerLink(graphA, topology)
expect(
store.updateEndpoint(graphA, topology, { targetSlot: 4 })
).toBeDefined()
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
expect(store.getInputSlotLink(graphA, toNodeId(9), 4)?.id).toBe(toLinkId(1))
})
it('keeps the first registration for a contested target slot', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
expect(store.registerLink(graphA, link(2, 5, 0, 9, 2))).toBeUndefined()
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('only the registered link can vacate its slot', () => {
const store = useLinkStore()
const registered = link(1, 5, 0, 9, 2)
const loser = link(2, 5, 0, 9, 2)
store.registerLink(graphA, registered)
store.registerLink(graphA, loser)
expect(store.deleteLink(graphA, loser)).toBe(false)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.deleteLink(graphA, registered)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
})
it('reports a lost placement when a target moves onto an occupied slot', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
const mover = link(2, 5, 1, 9, 3)
store.registerLink(graphA, mover)
expect(
store.updateEndpoint(graphA, mover, { targetSlot: 2 })
).toBeUndefined()
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
expect(store.isInputSlotConnected(graphA, toNodeId(9), 3)).toBe(false)
expect(mover.targetSlot).toBe(2)
})
it('never answers target queries from floating links', () => {
const store = useLinkStore()
const inputFloating: LinkTopology = {
...link(1, 5, 0, 9, 2),
originNodeId: UNASSIGNED_NODE_ID,
originSlot: -1
}
expect(store.registerLink(graphA, inputFloating)).toBeDefined()
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(false)
const real = link(2, 5, 0, 9, 2)
expect(store.registerLink(graphA, real)).toBeDefined()
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(2))
expect(store.deleteLink(graphA, inputFloating)).toBe(true)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
})
it('re-keys a floating link that gains a real origin', () => {
const store = useLinkStore()
const floating: LinkTopology = {
...link(1, 5, 0, 9, 2),
originNodeId: UNASSIGNED_NODE_ID,
originSlot: -1
}
store.registerLink(graphA, floating)
expect(
store.updateEndpoint(graphA, floating, {
originNodeId: toNodeId(5),
originSlot: 0
})
).toBeDefined()
expect(store.getInputSlotLink(graphA, toNodeId(9), 2)?.id).toBe(toLinkId(1))
})
it('lets sibling subgraphs register output-node links without clobbering', () => {
const store = useLinkStore()
const first = link(1, 5, 0, Number(SUBGRAPH_OUTPUT_ID), 0)
const second = link(1, 7, 0, Number(SUBGRAPH_OUTPUT_ID), 0)
expect(store.registerLink(graphA, first)).toBeDefined()
expect(store.registerLink(graphA, second)).toBeDefined()
expect(store.deleteLink(graphA, first)).toBe(true)
expect(store.deleteLink(graphA, second)).toBe(true)
})
it('scopes by graph and does not clear on tab switch', () => {
const store = useLinkStore()
store.registerLink(graphA, link(1, 5, 0, 9, 2))
store.registerLink(graphB, link(1, 5, 0, 9, 2))
store.clearGraph(graphB)
expect(store.isInputSlotConnected(graphA, toNodeId(9), 2)).toBe(true)
expect(store.isInputSlotConnected(graphB, toNodeId(9), 2)).toBe(false)
})
})

157
src/stores/linkStore.ts Normal file
View File

@@ -0,0 +1,157 @@
import { defineStore } from 'pinia'
import { reactive, ref, toRaw } from 'vue'
import { SUBGRAPH_OUTPUT_ID } from '@/lib/litegraph/src/constants'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { LinkTopology } from '@/types/linkTopology'
import type { NodeId } from '@/types/nodeId'
import type { UUID } from '@/utils/uuid'
export type EndpointPatch = Partial<
Pick<
LinkTopology,
'originNodeId' | 'originSlot' | 'targetNodeId' | 'targetSlot'
>
>
type TargetIndex = Map<UUID, Map<string, LinkTopology>>
type UnkeyedLinks = Map<UUID, Set<LinkTopology>>
/** Slot is numeric so the last separator is unambiguous for any node id. */
function targetKey(nodeId: NodeId, slot: number): string {
return `${nodeId}:${slot}`
}
/**
* A link is keyed by its target input slot only when that slot uniquely
* identifies it: floating links (either endpoint unassigned) can share an
* input slot with a real link, and SUBGRAPH_OUTPUT_ID is a constant shared by
* every subgraph in a root bucket. Neither is queried by target.
*/
function hasUniqueTarget(topology: LinkTopology): boolean {
return (
topology.originNodeId !== UNASSIGNED_NODE_ID &&
topology.targetNodeId !== UNASSIGNED_NODE_ID &&
topology.targetNodeId !== SUBGRAPH_OUTPUT_ID
)
}
/**
* Link topology store, keyed by link target. At most one live link can target
* a given input slot — litegraph disconnects the previous link before
* connecting a new one — so the target is the natural primary key and the
* dominant query ("is this input connected, and by what?") is one lookup.
* Links without a unique target live in a per-graph side collection.
*/
export const useLinkStore = defineStore('link', () => {
const targetIndex = ref<TargetIndex>(new Map())
const unkeyedLinks = ref<UnkeyedLinks>(new Map())
function graphTargets(graphId: UUID): Map<string, LinkTopology> {
const existing = targetIndex.value.get(graphId)
if (existing) return existing
const next = reactive(new Map<string, LinkTopology>())
targetIndex.value.set(graphId, next)
return next
}
function graphUnkeyed(graphId: UUID): Set<LinkTopology> {
const existing = unkeyedLinks.value.get(graphId)
if (existing) return existing
const next = reactive(new Set<LinkTopology>())
unkeyedLinks.value.set(graphId, next)
return next
}
/**
* Places a link under its current endpoints. The first registration for a
* target slot wins; re-placing the already-registered topology is a no-op.
* @returns The store-held reactive state when `topology` holds a
* registration afterwards — callers keep it as their live state object so
* later field writes are tracked — otherwise `undefined`.
*/
function place(
graphId: UUID,
topology: LinkTopology
): LinkTopology | undefined {
if (!hasUniqueTarget(topology)) {
graphUnkeyed(graphId).add(topology)
return reactive(topology)
}
const targets = graphTargets(graphId)
const key = targetKey(topology.targetNodeId, topology.targetSlot)
const existing = targets.get(key)
if (existing && toRaw(existing) !== toRaw(topology)) return undefined
targets.set(key, topology)
return reactive(topology)
}
/** Removes a link's placement; only the registered topology may vacate it. */
function displace(graphId: UUID, topology: LinkTopology): boolean {
if (unkeyedLinks.value.get(graphId)?.delete(topology)) return true
const targets = targetIndex.value.get(graphId)
if (!targets) return false
const key = targetKey(topology.targetNodeId, topology.targetSlot)
if (toRaw(targets.get(key)) !== toRaw(topology)) return false
return targets.delete(key)
}
/**
* Applies an endpoint patch and re-places the link under its new target.
* @returns The store-held reactive state when the link holds a
* registration afterwards, otherwise `undefined`.
*/
function updateEndpoint(
graphId: UUID,
topology: LinkTopology,
patch: EndpointPatch
): LinkTopology | undefined {
displace(graphId, topology)
const live = reactive(topology)
if (patch.originNodeId !== undefined) live.originNodeId = patch.originNodeId
if (patch.originSlot !== undefined) live.originSlot = patch.originSlot
if (patch.targetNodeId !== undefined) live.targetNodeId = patch.targetNodeId
if (patch.targetSlot !== undefined) live.targetSlot = patch.targetSlot
return place(graphId, topology)
}
function isInputSlotConnected(
graphId: UUID,
nodeId: NodeId,
slot: number
): boolean {
return targetIndex.value.get(graphId)?.has(targetKey(nodeId, slot)) ?? false
}
function getInputSlotLink(
graphId: UUID,
nodeId: NodeId,
slot: number
): LinkTopology | undefined {
return targetIndex.value.get(graphId)?.get(targetKey(nodeId, slot))
}
/** Iterates every registered topology in a graph's bucket. */
function* graphTopologies(graphId: UUID): Generator<LinkTopology> {
const targets = targetIndex.value.get(graphId)
if (targets) yield* targets.values()
const unkeyed = unkeyedLinks.value.get(graphId)
if (unkeyed) yield* unkeyed.values()
}
function clearGraph(graphId: UUID): void {
targetIndex.value.delete(graphId)
unkeyedLinks.value.delete(graphId)
}
return {
registerLink: place,
updateEndpoint,
deleteLink: displace,
isInputSlotConnected,
getInputSlotLink,
graphTopologies,
clearGraph
}
})

View File

@@ -0,0 +1,169 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { computed } from 'vue'
import { toLinkId } from '@/types/linkId'
import type { LinkTopology } from '@/types/linkTopology'
import { toNodeId, UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { RerouteChain } from '@/types/rerouteChain'
import { toRerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
import { useLinkStore } from './linkStore'
import { useRerouteStore } from './rerouteStore'
const graphA: UUID = 'graph-a'
const graphB: UUID = 'graph-b'
function chain(id: number, parentId?: number): RerouteChain {
return {
id: toRerouteId(id),
parentId: parentId === undefined ? undefined : toRerouteId(parentId)
}
}
function link(id: number, targetSlot: number, parentId?: number): LinkTopology {
return {
id: toLinkId(id),
originNodeId: toNodeId(5),
originSlot: 0,
targetNodeId: toNodeId(9),
targetSlot,
type: 'INT',
parentId: parentId === undefined ? undefined : toRerouteId(parentId)
}
}
describe('useRerouteStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('registers a chain and answers queries for it', () => {
const store = useRerouteStore()
store.registerReroute(graphA, chain(1))
expect(store.getReroute(graphA, toRerouteId(1))?.id).toBe(1)
expect(store.getReroute(graphA, toRerouteId(2))).toBeUndefined()
})
it('returns tracked state whose writes are observable', () => {
const store = useRerouteStore()
const registered = store.registerReroute(graphA, chain(2))
const parentId = computed(
() => store.getReroute(graphA, toRerouteId(2))?.parentId
)
expect(parentId.value).toBeUndefined()
registered.parentId = toRerouteId(1)
expect(parentId.value).toBe(1)
})
it('deletes a chain; only the registered state may vacate it', () => {
const store = useRerouteStore()
const registered = store.registerReroute(graphA, chain(1))
expect(store.deleteReroute(graphA, chain(1))).toBe(false)
expect(store.getReroute(graphA, toRerouteId(1))).toBeDefined()
expect(store.deleteReroute(graphA, registered)).toBe(true)
expect(store.getReroute(graphA, toRerouteId(1))).toBeUndefined()
expect(store.deleteReroute(graphA, registered)).toBe(false)
})
it('derives membership from the links parentId chains', () => {
const store = useRerouteStore()
const linkStore = useLinkStore()
store.registerReroute(graphA, chain(1))
store.registerReroute(graphA, chain(2, 1))
linkStore.registerLink(graphA, link(10, 0, 2))
linkStore.registerLink(graphA, link(11, 1, 1))
linkStore.registerLink(graphA, link(12, 2))
const terminal = store.getMembership(graphA, toRerouteId(2))
const upstream = store.getMembership(graphA, toRerouteId(1))
expect([...terminal.linkIds]).toEqual([10])
expect([...upstream.linkIds]).toEqual([10, 11])
expect(terminal.floatingLinkIds.size).toBe(0)
})
it('splits floating links into floatingLinkIds', () => {
const store = useRerouteStore()
const linkStore = useLinkStore()
store.registerReroute(graphA, chain(1))
linkStore.registerLink(graphA, {
...link(10, 0, 1),
targetNodeId: UNASSIGNED_NODE_ID,
targetSlot: -1
})
const membership = store.getMembership(graphA, toRerouteId(1))
expect([...membership.floatingLinkIds]).toEqual([10])
expect(membership.linkIds.size).toBe(0)
})
it('updates membership when a links parentId changes', () => {
const store = useRerouteStore()
const linkStore = useLinkStore()
store.registerReroute(graphA, chain(1))
const registered = linkStore.registerLink(graphA, link(10, 0))!
expect(store.getMembership(graphA, toRerouteId(1)).linkIds.size).toBe(0)
registered.parentId = toRerouteId(1)
expect([...store.getMembership(graphA, toRerouteId(1)).linkIds]).toEqual([
10
])
})
it('updates membership when a reroute is re-parented', () => {
const store = useRerouteStore()
const linkStore = useLinkStore()
store.registerReroute(graphA, chain(1))
const terminal = store.registerReroute(graphA, chain(2))
linkStore.registerLink(graphA, link(10, 0, 2))
expect(store.getMembership(graphA, toRerouteId(1)).linkIds.size).toBe(0)
terminal.parentId = toRerouteId(1)
expect([...store.getMembership(graphA, toRerouteId(1)).linkIds]).toEqual([
10
])
})
it('terminates membership walks on parentId cycles', () => {
const store = useRerouteStore()
const linkStore = useLinkStore()
store.registerReroute(graphA, chain(1, 2))
store.registerReroute(graphA, chain(2, 1))
linkStore.registerLink(graphA, link(10, 0, 2))
expect([...store.getMembership(graphA, toRerouteId(1)).linkIds]).toEqual([
10
])
expect([...store.getMembership(graphA, toRerouteId(2)).linkIds]).toEqual([
10
])
})
it('scopes buckets by graph', () => {
const store = useRerouteStore()
store.registerReroute(graphA, chain(1))
store.registerReroute(graphB, chain(1, 7))
expect(store.getReroute(graphA, toRerouteId(1))?.parentId).toBeUndefined()
expect(store.getReroute(graphB, toRerouteId(1))?.parentId).toBe(7)
store.clearGraph(graphB)
expect(store.getReroute(graphA, toRerouteId(1))).toBeDefined()
expect(store.getReroute(graphB, toRerouteId(1))).toBeUndefined()
})
})

133
src/stores/rerouteStore.ts Normal file
View File

@@ -0,0 +1,133 @@
import { defineStore } from 'pinia'
import { computed, reactive, ref, toRaw } from 'vue'
import type { ComputedRef } from 'vue'
import { useLinkStore } from '@/stores/linkStore'
import type { LinkId } from '@/types/linkId'
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
import type { RerouteChain } from '@/types/rerouteChain'
import type { RerouteId } from '@/types/rerouteId'
import type { UUID } from '@/utils/uuid'
/** The links whose chains pass through a reroute, split by link liveness. */
export interface RerouteMembership {
linkIds: ReadonlySet<LinkId>
floatingLinkIds: ReadonlySet<LinkId>
}
export const EMPTY_MEMBERSHIP: Readonly<RerouteMembership> = {
linkIds: new Set(),
floatingLinkIds: new Set()
} as const
/**
* Reroute chain store, holding each reroute's chain state (parent pointer
* and floating slot marker) in root-graph-scoped buckets keyed by
* `RerouteId`. Link membership is not stored; it is derived from the links'
* parentId chains. See docs/architecture/reroute-chain-store.md.
*/
export const useRerouteStore = defineStore('reroute', () => {
const chains = ref(new Map<UUID, Map<RerouteId, RerouteChain>>())
const membershipIndexes = new Map<
UUID,
ComputedRef<Map<RerouteId, RerouteMembership>>
>()
function graphChains(graphId: UUID): Map<RerouteId, RerouteChain> {
const existing = chains.value.get(graphId)
if (existing) return existing
const next = reactive(new Map<RerouteId, RerouteChain>())
chains.value.set(graphId, next)
return next
}
/**
* Builds the reverse index from the links' parentId chains: a link is a
* member of exactly the reroutes on the chain walked from its terminal
* reroute upstream. A link with an unassigned endpoint is floating.
*/
function buildMembershipIndex(
graphId: UUID
): Map<RerouteId, RerouteMembership> {
const bucket = chains.value.get(graphId)
const index = new Map<
RerouteId,
{ linkIds: Set<LinkId>; floatingLinkIds: Set<LinkId> }
>()
for (const topology of useLinkStore().graphTopologies(graphId)) {
const floating =
topology.originNodeId === UNASSIGNED_NODE_ID ||
topology.targetNodeId === UNASSIGNED_NODE_ID
const visited = new Set<RerouteId>()
let rerouteId = topology.parentId
while (rerouteId !== undefined && !visited.has(rerouteId)) {
visited.add(rerouteId)
let entry = index.get(rerouteId)
if (!entry) {
entry = { linkIds: new Set(), floatingLinkIds: new Set() }
index.set(rerouteId, entry)
}
const members = floating ? entry.floatingLinkIds : entry.linkIds
members.add(topology.id)
rerouteId = bucket?.get(rerouteId)?.parentId
}
}
return index
}
function graphMembership(
graphId: UUID
): ComputedRef<Map<RerouteId, RerouteMembership>> {
const existing = membershipIndexes.get(graphId)
if (existing) return existing
const next = computed(() => buildMembershipIndex(graphId))
membershipIndexes.set(graphId, next)
return next
}
function getMembership(
graphId: UUID,
rerouteId: RerouteId
): RerouteMembership {
return graphMembership(graphId).value.get(rerouteId) ?? EMPTY_MEMBERSHIP
}
/**
* Registers a reroute's chain state.
* @returns The store-held reactive state — callers keep it as their live
* state object so later field writes are tracked.
*/
function registerReroute(graphId: UUID, chain: RerouteChain): RerouteChain {
const bucket = graphChains(graphId)
bucket.set(chain.id, chain)
return bucket.get(chain.id)!
}
function getReroute(
graphId: UUID,
rerouteId: RerouteId
): RerouteChain | undefined {
return chains.value.get(graphId)?.get(rerouteId)
}
/** Removes a chain's registration; only the registered state may vacate it. */
function deleteReroute(graphId: UUID, chain: RerouteChain): boolean {
const bucket = chains.value.get(graphId)
if (!bucket) return false
if (toRaw(bucket.get(chain.id)) !== toRaw(chain)) return false
return bucket.delete(chain.id)
}
function clearGraph(graphId: UUID): void {
chains.value.delete(graphId)
membershipIndexes.delete(graphId)
}
return {
registerReroute,
getReroute,
deleteReroute,
getMembership,
clearGraph
}
})

View File

@@ -141,7 +141,7 @@ describe('useWidgetValueStore', () => {
expect(registered?.value).toBe(100)
})
it('getNodeWidgets returns all widgets for a node', () => {
it('getNodeWidgets returns widgets in registration order', () => {
const store = useWidgetValueStore()
store.registerWidget(
widgetId(graphA, toNodeId('node-1'), 'seed'),
@@ -157,8 +157,40 @@ describe('useWidgetValueStore', () => {
)
const widgets = store.getNodeWidgets(graphA, toNodeId('node-1'))
expect(widgets).toHaveLength(2)
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
expect(widgets.map((w) => w.name)).toEqual(['seed', 'steps'])
})
it('getNodeWidgetIds returns the explicit node widget order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
const cfg = widgetId(graphA, toNodeId('node-1'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(steps, state('number', 20))
store.registerWidget(cfg, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [cfg, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
cfg,
seed,
steps
])
expect(
store.getNodeWidgets(graphA, toNodeId('node-1')).map((w) => w.name)
).toEqual(['cfg', 'seed', 'steps'])
})
it('ignores widget IDs from other nodes when setting order', () => {
const store = useWidgetValueStore()
const seed = widgetId(graphA, toNodeId('node-1'), 'seed')
const other = widgetId(graphA, toNodeId('node-2'), 'cfg')
store.registerWidget(seed, state('number', 1))
store.registerWidget(other, state('number', 7))
store.setNodeWidgetOrder(graphA, toNodeId('node-1'), [other, seed])
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([seed])
})
})
@@ -174,14 +206,33 @@ describe('useWidgetValueStore', () => {
).toBe(false)
})
it('deleteWidget removes registered widgets', () => {
it('deleteWidget removes registered widgets from node order', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
expect(store.deleteWidget(seedA)).toBe(true)
expect(store.getWidget(seedA)).toBeUndefined()
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.deleteWidget(seedA)).toBe(false)
})
it('removeNodeWidgetOrder drops the id from order but keeps its value', () => {
const store = useWidgetValueStore()
const steps = widgetId(graphA, toNodeId('node-1'), 'steps')
store.registerWidget(seedA, state('number', 100))
store.registerWidget(steps, state('number', 20))
store.removeNodeWidgetOrder(seedA)
expect(store.getNodeWidgetIds(graphA, toNodeId('node-1'))).toEqual([
steps
])
expect(store.getWidget(seedA)?.value).toBe(100)
})
})
describe('direct property mutation', () => {

View File

@@ -8,12 +8,23 @@ import { parseWidgetId } from '@/types/widgetId'
import type { WidgetId } from '@/types/widgetId'
import type { WidgetState, WidgetStateInit } from '@/types/widgetState'
export interface WidgetRenderState {
advanced?: boolean
hasLayoutSize?: boolean
isDOMWidget?: boolean
tooltip?: string
}
export function stripGraphPrefix(scopedId: SerializedNodeId): NodeId | null {
return parseNodeId(String(scopedId).replace(/^(.*:)+/, ''))
}
export const useWidgetValueStore = defineStore('widgetValue', () => {
const graphWidgetStates = ref(new Map<UUID, Map<WidgetId, WidgetState>>())
const graphWidgetRenderStates = ref(
new Map<UUID, Map<WidgetId, WidgetRenderState>>()
)
const graphNodeWidgetOrders = ref(new Map<UUID, Map<NodeId, WidgetId[]>>())
function getGraphWidgetStates(graphId: UUID): Map<WidgetId, WidgetState> {
const widgetStates = graphWidgetStates.value.get(graphId)
@@ -24,12 +35,67 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
return nextWidgetStates
}
function getGraphWidgetRenderStates(
graphId: UUID
): Map<WidgetId, WidgetRenderState> {
const widgetRenderStates = graphWidgetRenderStates.value.get(graphId)
if (widgetRenderStates) return widgetRenderStates
const nextWidgetRenderStates = reactive(
new Map<WidgetId, WidgetRenderState>()
)
graphWidgetRenderStates.value.set(graphId, nextWidgetRenderStates)
return nextWidgetRenderStates
}
function getGraphNodeWidgetOrders(graphId: UUID): Map<NodeId, WidgetId[]> {
const widgetOrders = graphNodeWidgetOrders.value.get(graphId)
if (widgetOrders) return widgetOrders
const nextWidgetOrders = reactive(new Map<NodeId, WidgetId[]>())
graphNodeWidgetOrders.value.set(graphId, nextWidgetOrders)
return nextWidgetOrders
}
function getNodeWidgetOrder(graphId: UUID, nodeId: NodeId): WidgetId[] {
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (order) return order
const nextOrder = reactive<WidgetId[]>([])
graphOrders.set(nodeId, nextOrder)
return nextOrder
}
function appendNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const order = getNodeWidgetOrder(graphId, nodeId)
if (!order.includes(widgetId)) order.push(widgetId)
}
function removeNodeWidgetOrder(widgetId: WidgetId): void {
const { graphId, nodeId } = parseWidgetId(widgetId)
const graphOrders = getGraphNodeWidgetOrders(graphId)
const order = graphOrders.get(nodeId)
if (!order) return
const index = order.indexOf(widgetId)
if (index !== -1) order.splice(index, 1)
if (order.length === 0) graphOrders.delete(nodeId)
}
function registerWidget<TValue = unknown>(
widgetId: WidgetId,
init: WidgetStateInit<TValue>
init: WidgetStateInit<TValue>,
renderState: WidgetRenderState = {}
): WidgetState<TValue> {
registerWidgetRenderState(widgetId, renderState)
const existing = getWidget(widgetId)
if (existing) return existing as WidgetState<TValue>
if (existing) {
appendNodeWidgetOrder(widgetId)
return existing as WidgetState<TValue>
}
const { graphId, nodeId, name } = parseWidgetId(widgetId)
const state: WidgetState<TValue> = {
@@ -40,14 +106,39 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
}
const widgetStates = getGraphWidgetStates(graphId)
widgetStates.set(widgetId, state)
appendNodeWidgetOrder(widgetId)
return widgetStates.get(widgetId) as WidgetState<TValue>
}
function registerWidgetRenderState(
widgetId: WidgetId,
init: WidgetRenderState
): WidgetRenderState {
const { graphId } = parseWidgetId(widgetId)
const widgetRenderStates = getGraphWidgetRenderStates(graphId)
const existing = widgetRenderStates.get(widgetId)
if (existing) {
Object.assign(existing, init)
return existing
}
const state: WidgetRenderState = { ...init }
widgetRenderStates.set(widgetId, state)
return widgetRenderStates.get(widgetId) as WidgetRenderState
}
function getWidget(widgetId: WidgetId): WidgetState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetStates(graphId).get(widgetId)
}
function getWidgetRenderState(
widgetId: WidgetId
): WidgetRenderState | undefined {
const { graphId } = parseWidgetId(widgetId)
return getGraphWidgetRenderStates(graphId).get(widgetId)
}
function setValue(widgetId: WidgetId, value: WidgetState['value']): boolean {
const state = getWidget(widgetId)
if (!state) return false
@@ -57,25 +148,70 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
function deleteWidget(widgetId: WidgetId): boolean {
const { graphId } = parseWidgetId(widgetId)
getGraphWidgetRenderStates(graphId).delete(widgetId)
removeNodeWidgetOrder(widgetId)
return getGraphWidgetStates(graphId).delete(widgetId)
}
function getNodeWidgets(graphId: UUID, localNodeId: NodeId): WidgetState[] {
return [...getGraphWidgetStates(graphId).values()].filter(
(state) => state.nodeId === localNodeId
return getNodeWidgetIds(graphId, localNodeId).flatMap((id) => {
const state = getWidget(id)
return state ? [state] : []
})
}
/**
* Merges a requested widget order against the ids already tracked for the
* node: the request is filtered to tracked ids, then any tracked id the
* request omitted is appended. Tracked ids are never dropped here — only
* {@link removeNodeWidgetOrder} removes an id from the order.
*/
function reconcileNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): WidgetId[] {
const currentOrder = getNodeWidgetIds(graphId, localNodeId)
const currentIds = new Set(currentOrder)
const nextOrder = orderedWidgetIds.filter((id) => currentIds.has(id))
const nextIds = new Set(nextOrder)
return [...nextOrder, ...currentOrder.filter((id) => !nextIds.has(id))]
}
function getNodeWidgetIds(graphId: UUID, localNodeId: NodeId): WidgetId[] {
return [...getNodeWidgetOrder(graphId, localNodeId)]
}
function setNodeWidgetOrder(
graphId: UUID,
localNodeId: NodeId,
orderedWidgetIds: readonly WidgetId[]
): void {
const nextOrder = reconcileNodeWidgetOrder(
graphId,
localNodeId,
orderedWidgetIds
)
const order = getNodeWidgetOrder(graphId, localNodeId)
order.splice(0, order.length, ...nextOrder)
}
function clearGraph(graphId: UUID): void {
graphWidgetStates.value.delete(graphId)
graphWidgetRenderStates.value.delete(graphId)
graphNodeWidgetOrders.value.delete(graphId)
}
return {
registerWidget,
getWidget,
getWidgetRenderState,
setValue,
deleteWidget,
getNodeWidgets,
getNodeWidgetIds,
setNodeWidgetOrder,
removeNodeWidgetOrder,
clearGraph
}
})

17
src/types/linkTopology.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
import type { LinkId } from '@/types/linkId'
import type { NodeId } from '@/types/nodeId'
import type { RerouteId } from '@/types/rerouteId'
export interface LinkTopology {
id: LinkId
/** Output node; UNASSIGNED_NODE_ID when the output end is floating. */
originNodeId: NodeId
originSlot: number
/** Input node; UNASSIGNED_NODE_ID when the input end is floating. */
targetNodeId: NodeId
targetSlot: number
type: ISlotType
/** Terminal reroute of the segment chain, when the link routes through one. */
parentId?: RerouteId
}

18
src/types/rerouteChain.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { RerouteId } from '@/types/rerouteId'
/** The input or output slot that an incomplete reroute link is connected to. */
export interface FloatingRerouteSlot {
/** Floating connection to an input or output */
slotType: 'input' | 'output'
}
/**
* A reroute's chain state: its upstream neighbour and, on the last reroute
* of a floating chain, which slot side the chain still faces. Link
* membership is not stored — it is derived from the links' parentId chains.
*/
export interface RerouteChain {
readonly id: RerouteId
parentId?: RerouteId
floating?: FloatingRerouteSlot
}

Some files were not shown because too many files have changed in this diff Show More