mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-14 19:27:09 +00:00
Compare commits
4 Commits
version-bu
...
feature/ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
368b5bedd4 | ||
|
|
3e458f5e6f | ||
|
|
9c7af27a52 | ||
|
|
14d55eb305 |
@@ -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 |
@@ -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 |
@@ -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')
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
33
docs/architecture/domain-glossary.md
Normal file
33
docs/architecture/domain-glossary.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,7 +18,12 @@ Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
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<nodeLocatorId, outputs>"]
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
112
docs/architecture/link-topology-store.md
Normal file
112
docs/architecture/link-topology-store.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
118
docs/architecture/reroute-chain-store.md
Normal file
118
docs/architecture/reroute-chain-store.md
Normal 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.
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
221
src/lib/litegraph/src/LLink.store.test.ts
Normal file
221
src/lib/litegraph/src/LLink.store.test.ts
Normal 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 definition’s 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 definition’s 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 graph’s 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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
236
src/lib/litegraph/src/Reroute.store.test.ts
Normal file
236
src/lib/litegraph/src/Reroute.store.test.ts
Normal 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 graph’s 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
93
src/lib/litegraph/src/canvas/FloatingRenderLink.test.ts
Normal file
93
src/lib/litegraph/src/canvas/FloatingRenderLink.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,7 +85,6 @@ describe('Vue Node - Subgraph Functionality', () => {
|
||||
selected: false,
|
||||
executing: false,
|
||||
subgraphId,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
hasErrors: false,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,7 +26,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets: [],
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
flags: { collapsed: false },
|
||||
|
||||
@@ -38,7 +38,6 @@ const makeNodeData = (overrides: Partial<VueNodeData> = {}): VueNodeData => ({
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
flags: { collapsed: false },
|
||||
...overrides
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
104
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal file
104
src/renderer/extensions/vueNodes/components/WidgetGrid.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
33
src/renderer/extensions/vueNodes/types/widgetGrid.ts
Normal file
33
src/renderer/extensions/vueNodes/types/widgetGrid.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
157
src/stores/linkStore.test.ts
Normal file
157
src/stores/linkStore.test.ts
Normal 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
157
src/stores/linkStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
169
src/stores/rerouteStore.test.ts
Normal file
169
src/stores/rerouteStore.test.ts
Normal 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 link’s 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
133
src/stores/rerouteStore.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
17
src/types/linkTopology.ts
Normal 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
18
src/types/rerouteChain.ts
Normal 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
Reference in New Issue
Block a user