Compare commits
106 Commits
matt/be-22
...
drjkl/inpu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99520265ac | ||
|
|
f4ceedb787 | ||
|
|
b4fa1ed3f1 | ||
|
|
a4f38f71e2 | ||
|
|
603154e24f | ||
|
|
5652a1c23f | ||
|
|
b71340970e | ||
|
|
32b3c28a1c | ||
|
|
1b9906efbb | ||
|
|
9f3d11a0ba | ||
|
|
e6ab296c94 | ||
|
|
0679deb5f9 | ||
|
|
7400d4e6f2 | ||
|
|
1940be51d7 | ||
|
|
a71df2f1b5 | ||
|
|
a310989209 | ||
|
|
84693005e4 | ||
|
|
51ba284f7f | ||
|
|
4c9a2b1312 | ||
|
|
7e08de9e3b | ||
|
|
dce951b819 | ||
|
|
0d0a3dbdec | ||
|
|
cb9ebecee8 | ||
|
|
8c43b39e7b | ||
|
|
070ea46873 | ||
|
|
e09b47ffe5 | ||
|
|
c00b778059 | ||
|
|
64bce0e602 | ||
|
|
a6b9d4e254 | ||
|
|
dd3dd63b25 | ||
|
|
c306c567f1 | ||
|
|
2185f61b51 | ||
|
|
29c7781d0a | ||
|
|
d07248890c | ||
|
|
f5c5d10897 | ||
|
|
0b959753ee | ||
|
|
2d51f2ca43 | ||
|
|
45d0566ff9 | ||
|
|
62dedcc0dd | ||
|
|
01c0c8e86d | ||
|
|
fcba5b297c | ||
|
|
bdf47aebca | ||
|
|
43a4058be9 | ||
|
|
18c9e769a4 | ||
|
|
567643abe3 | ||
|
|
75feebdf5e | ||
|
|
d14ed83e1e | ||
|
|
cb3e757497 | ||
|
|
56f2abc519 | ||
|
|
1fb64cec49 | ||
|
|
1038264ae3 | ||
|
|
fc55165eae | ||
|
|
da9280d13c | ||
|
|
056531b59a | ||
|
|
b54a1a3471 | ||
|
|
31ef2d0dfe | ||
|
|
1d8e6e2b9b | ||
|
|
327e3f84f0 | ||
|
|
cf8f87c37f | ||
|
|
af77a27567 | ||
|
|
8978502e8d | ||
|
|
394ff10b23 | ||
|
|
53dde242bd | ||
|
|
310de8ee08 | ||
|
|
d4ad799f0e | ||
|
|
2f1e14c53f | ||
|
|
495ea990f3 | ||
|
|
c8692de343 | ||
|
|
1597d20bed | ||
|
|
e38dca7f5f | ||
|
|
02790eaa28 | ||
|
|
068eb13ee0 | ||
|
|
8d17098926 | ||
|
|
ec7dbde89c | ||
|
|
0175ab9aa2 | ||
|
|
0c073cac53 | ||
|
|
af8812e171 | ||
|
|
b8e8b5b53a | ||
|
|
003dcea90a | ||
|
|
0fb8625abf | ||
|
|
510dd01e8b | ||
|
|
b8d319b187 | ||
|
|
a505780953 | ||
|
|
ebb0f25231 | ||
|
|
4fad4889fa | ||
|
|
68255992ec | ||
|
|
b2c479479e | ||
|
|
b49eac4753 | ||
|
|
4ddf08240d | ||
|
|
d831b1de54 | ||
|
|
4fb92e0bf6 | ||
|
|
20fe604ff9 | ||
|
|
a99fe3a1df | ||
|
|
b6ebd544c7 | ||
|
|
ee0f984c21 | ||
|
|
43b4e50697 | ||
|
|
c6e67b38b7 | ||
|
|
5b065a299e | ||
|
|
97298116ee | ||
|
|
a4eab62f35 | ||
|
|
4dac3ccb22 | ||
|
|
66bad3b522 | ||
|
|
f1b98c63bd | ||
|
|
f717873025 | ||
|
|
aa3d192636 | ||
|
|
5b98697bcf |
@@ -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,
|
||||
|
||||
@@ -48,25 +48,6 @@ test.describe('Node Badge', { tag: ['@screenshot', '@smoke', '@node'] }, () => {
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-multiple.png')
|
||||
})
|
||||
|
||||
test('Can add badge left-side', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const LGraphBadge = window.LGraphBadge!
|
||||
const app = window.app as ComfyApp
|
||||
const graph = app.graph
|
||||
const nodes = graph.nodes
|
||||
|
||||
for (const node of nodes) {
|
||||
node.badges = [new LGraphBadge({ text: 'Test Badge' })]
|
||||
// @ts-expect-error - Enum value
|
||||
node.badgePosition = 'top-left'
|
||||
}
|
||||
|
||||
graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('node-badge-left.png')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
|
||||
|
Before Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
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 |
|
||||
|
||||
50
docs/architecture/domain-glossary.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# 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),
|
||||
[Node Badge Store](node-badge-store.md),
|
||||
[ADR 0008](../adr/0008-entity-component-system.md).
|
||||
|
||||
## Badges
|
||||
|
||||
- **Badge** — a small visual annotation rendered on a node: its numeric
|
||||
id, lifecycle state, source pack, execution price, or an
|
||||
extension-provided marker. Badges are presentation state; they never
|
||||
affect execution and are never persisted with the workflow.
|
||||
- **Badge kind** — the category a badge belongs to: **core** (identity /
|
||||
lifecycle / source, projected from the node's definition and user
|
||||
settings), **credits** (price of executing an API node, including
|
||||
aggregated prices of nodes inside a subgraph), or **extension**
|
||||
(provided by third-party code).
|
||||
- **Badge source** — the domain state a badge's content is computed
|
||||
from (settings, node definition, palette, pricing, widget values,
|
||||
input connectivity). A badge is always a projection of its sources;
|
||||
it is never authored directly by a user.
|
||||
|
||||
## 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
|
||||
|
||||
119
docs/architecture/link-topology-store.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 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` is **identity-checked** (`toRaw` comparison): only the
|
||||
registered topology can vacate its slot.
|
||||
- `updateEndpoint` re-keys atomically — identity-checked displace,
|
||||
patch fields through a reactive wrapper, re-place under the new key —
|
||||
and the move is **authoritative**: an incumbent under the new key is
|
||||
evicted. First-wins only guards _registration_; a re-key is a move of
|
||||
a link that already proved ownership by vacating its old key, so the
|
||||
write wins. This is what keeps slot permutations safe when callers
|
||||
re-key links one write at a time (input reorder, splice shifts): a
|
||||
transiently evicted link re-places itself when its own endpoint write
|
||||
arrives, and every lawful final state is collision-free. 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 `output.links` and `input.link`
|
||||
slot mirrors have since been deleted — the store is the single source
|
||||
for slot connectivity in both directions (see
|
||||
[output slot connectivity](output-slot-connectivity.md) Decision 6);
|
||||
the remaining fields are deprecated warning getters kept as extension
|
||||
migration telemetry.
|
||||
188
docs/architecture/node-badge-store.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Node Badge Store
|
||||
|
||||
Date: 2026-07-05 (updated 2026-07-06)
|
||||
Status: Implemented — slice A (store + system) and slice B (consumer
|
||||
cutover, resolved decisions below) shipped as
|
||||
`src/stores/nodeBadgeStore.ts`, `src/systems/badgeSystem.ts`,
|
||||
`src/lib/litegraph/src/nodeBadgeDraw.ts`. Follow-up to the
|
||||
[link topology store](link-topology-store.md),
|
||||
[reroute chain store](reroute-chain-store.md), and the
|
||||
[node data store draft](node-data-store.md)
|
||||
|
||||
Design record for extracting node badges off `LGraphNode` instances into
|
||||
a dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
going straight to plain-data components — no interim closure storage.
|
||||
Vocabulary: [domain glossary § Badges](domain-glossary.md#badges).
|
||||
|
||||
## Current state (what this replaces)
|
||||
|
||||
`LGraphNode.badges: (LGraphBadge | (() => LGraphBadge))[]` mixes three
|
||||
things: a **core badge closure** (id / lifecycle / source, re-derived
|
||||
independently by the Vue renderer, which skips it positionally via
|
||||
`slice(1)`), **credits badge closures** (`creditsBadgeGetter`,
|
||||
`buildWrapperAwarePriceBadge` — hand-rolled reactive computeds the
|
||||
legacy canvas polls per frame), and a public push surface for
|
||||
extensions. Badge changes are announced by a single manual
|
||||
`node:property:changed` trigger in `usePriceBadge`; plain pushes are
|
||||
invisible. Credits badges are classified by icon identity
|
||||
(`icon.image === componentIconSvg`). Badges never serialize (verified:
|
||||
no `badges` key in `serialize()`/`configure`).
|
||||
|
||||
Every closure is a reactive computed feeding an unreactive array, and
|
||||
the Vue renderer has already re-implemented all of their reactivity as
|
||||
store-tracked dependencies. The design deletes the closures and makes
|
||||
the store rows the single truth both renderers read.
|
||||
|
||||
## Decision 1: Plain `BadgeData` rows, no interim shape
|
||||
|
||||
The store holds only plain data (ADR 0008 component rule):
|
||||
|
||||
```
|
||||
BadgeData {
|
||||
kind: 'core' | 'credits' | 'extension'
|
||||
text: string
|
||||
fgColor?: string
|
||||
bgColor?: string
|
||||
iconKey?: string // resolved via a small icon registry ('credits' → SVG)
|
||||
}
|
||||
```
|
||||
|
||||
No `onClick` (no producer exists; `LGraphButton`/`title_buttons` are a
|
||||
separate surface and out of scope). No `Image` objects — icons are
|
||||
referenced by key. A `commandId` field can be added if a clickable badge
|
||||
requirement ever materialises.
|
||||
|
||||
Rejected: an interim `BadgeEntry { kind, source: LGraphBadge | thunk }`
|
||||
phase. The public element type of `node.badges` breaks either way; one
|
||||
break that lands on the end-state shape beats two.
|
||||
|
||||
## Decision 2: Store shape and keying
|
||||
|
||||
`nodeBadgeStore`: root-graph-scoped buckets (`rootGraph.id`) keyed by
|
||||
`NodeId`, each holding an ordered reactive `BadgeData[]`. Register /
|
||||
unregister / unregister-all trio at the `LGraph.add` / `LGraph.remove` /
|
||||
`clear()` chokepoints, identity-checked deletes — the shipped store
|
||||
conventions.
|
||||
|
||||
Rows are partitioned by `kind` at read time; the positional `slice(1)`
|
||||
convention and icon-identity credits detection are deleted. Display
|
||||
order is kind order (core, credits, extension), not insertion order.
|
||||
|
||||
## Decision 3: A reactive BadgeSystem writes the rows
|
||||
|
||||
One system module owns the recomputation: per registered node, an
|
||||
`effectScope` runs a pure `computeBadges(sources) → BadgeData[]`
|
||||
function inside a thin watch shell and writes the node's rows. Sources
|
||||
are the existing stores: `nodeDefStore`, `settingStore`,
|
||||
`colorPaletteStore`, `useNodePricing` revision refs, `widgetValueStore`,
|
||||
`linkStore` input connectivity. The pure function is the future
|
||||
command-pipeline phase body (ADR 0003 systems ADR); only the scheduler
|
||||
shell changes when that lands.
|
||||
|
||||
`useNodeBadge` / `usePriceBadge` stop pushing closures; their derivation
|
||||
logic moves into `computeBadges`. The manual `'badges'`
|
||||
`node:property:changed` trigger, the `badges` case in
|
||||
`useGraphNodeManager`, and `VueNodeData.badges` are deleted.
|
||||
`usePartitionedBadges` collapses to a store query partitioned by kind;
|
||||
its manual dependency-touching (`trackNodePrice`,
|
||||
`trackSubgraphInnerNodePrices`) moves inside the system.
|
||||
|
||||
## Decision 4: Core badges are system-written rows too
|
||||
|
||||
Core (#id / lifecycle / source) badges are materialized by the system
|
||||
like every other kind, so both renderers consume one uniform row set.
|
||||
This kills the current dual derivation (legacy closure + Vue-side
|
||||
re-derivation). Materialized-by-system is the ECS write path, not a
|
||||
mirror: no other component stores this projection.
|
||||
|
||||
## Decision 5: Legacy canvas consumes rows via a draw cache
|
||||
|
||||
`drawBadges` renders from `BadgeData`, constructing and caching
|
||||
`LGraphBadge` draw objects keyed by row content (the `Reroute` id-badge
|
||||
pattern, memoized). `_boundingRect` hit-test state stays renderer-side.
|
||||
Frame-budget parity per ADR 0008's render mitigations applies.
|
||||
|
||||
## Implementation notes (slice A)
|
||||
|
||||
- Registration is bucket-key presence: the `LGraph.add`/`remove`/`clear`
|
||||
chokepoints call the import-light store trio
|
||||
(`registerNode`/`unregisterNode`/`clearGraph`), and the system watches
|
||||
`registeredNodeIds` to attach/detach per-node effect scopes. Litegraph
|
||||
never imports the system, so the pricing/nodeDef dependency graph
|
||||
(which runtime-imports the litegraph barrel) stays acyclic. The system
|
||||
takes `resolveGraphId`/`resolveNode` seams and is bootstrapped once at
|
||||
the app layer by the `Comfy.NodeBadge` extension (`useNodeBadge`).
|
||||
The graph id is resolved live on every recompute because
|
||||
`LGraph.clear()` and `configure()` reassign the root graph's id; a
|
||||
captured id strands the system on a dead bucket. Row writes are
|
||||
refused for unregistered nodes so a late effect flush cannot
|
||||
resurrect a bucket key the chokepoints deleted. `LGraph.add`
|
||||
registers the node only after `onNodeAdded`: the store write wakes
|
||||
the system's watcher and queues Vue's flush microtask, which must not
|
||||
overtake the paste-scan microtask `useErrorClearingHooks` enqueues
|
||||
from `onNodeAdded`.
|
||||
- The shell still reads `node.constructor.nodeData` and `node.inputs`
|
||||
(untracked instance state) to map pricing input names to slot
|
||||
indices — parity with the legacy closures. Those reads become store
|
||||
lookups when slot data is store-backed (node data store draft).
|
||||
- Core rows are fine-grained — one row per part, tagged
|
||||
`part: 'lifecycle' | 'id' | 'source'` and carrying the raw projected
|
||||
text — with one visibility rule (`badgeTextVisible`, the legacy
|
||||
semantics: `HideBuiltIn` respected for every part). Each renderer owns
|
||||
presentation: the legacy draw cache joins parts in id, lifecycle,
|
||||
source order and truncates; the Vue partition trims lifecycle
|
||||
brackets, and replaces a built-in node's source row with its
|
||||
Comfy-logo chip. Known unification effect: the Vue renderer gains
|
||||
`HideBuiltIn` handling for id and lifecycle badges on built-in nodes.
|
||||
- A credits row is emitted only when the display price label is
|
||||
non-empty; async pricing fills it via the per-node revision ref.
|
||||
|
||||
## Implementation notes (slice B)
|
||||
|
||||
- Wrapper `SubgraphNode` credits rows are aggregated by the system over
|
||||
the inner (recursively collected, non-wrapper) api nodes — the same
|
||||
count basis as the old unconditional per-api-node badges: several
|
||||
collapse to `Partner Nodes x N` (localized), exactly one shows its
|
||||
price with the wrapper's promoted widget values overriding the inner
|
||||
node's own. The aggregation tracks the inner nodes' pricing
|
||||
revisions, the registered-node set, and a structure revision.
|
||||
`useNodePricing` caches labels per signature so a leaf's own read and
|
||||
its wrapper's override read do not evict each other and re-schedule
|
||||
forever.
|
||||
- Legacy `drawBadges` renders the store rows through a per-node
|
||||
memoized `LGraphBadge` cache (decision 5); `iconKey` resolves through
|
||||
a generic litegraph `badgeIconRegistry`, with the `credits` icon
|
||||
registered by the system module.
|
||||
|
||||
## Resolved decisions (2026-07-06 interview)
|
||||
|
||||
1. **Legacy `node.badges` surface — kept as a raw extension-only push
|
||||
surface.** Directive: delete unless actively used. A GitHub-wide scan
|
||||
found three custom-node packs actively writing it
|
||||
(ComfyUI-Wildcard-Pipeline pushes and splices `LGraphBadge`
|
||||
instances; ComfyUI-Enhancement-Utils and ComfyUI-XENodes push
|
||||
per-frame getter thunks for ticking execution-time badges), so the
|
||||
array stays, is no longer written by core/credits code, and both
|
||||
renderers append it after the store rows — thunks still evaluate per
|
||||
frame in the legacy canvas. The earlier "zero third-party writers"
|
||||
scan was wrong.
|
||||
2. **`node.badgePosition` — deleted**, along with the `BadgePosition`
|
||||
enum. Zero uses outside this repo; the legacy canvas now always
|
||||
renders badges top-right (the only surviving behaviour).
|
||||
3. **Subgraph credits aggregation triggers — event-bumped revision.**
|
||||
`litegraph:set-graph`, `subgraph-converted`, and
|
||||
`afterConfigureGraph` bump `bumpSubgraphCreditsRevision()`; swap to
|
||||
reactive `SubgraphStructure` dependencies when that state is
|
||||
store-backed. Aggregation behaviour itself is preserved (explicit
|
||||
user requirement).
|
||||
|
||||
## Scope and sequencing
|
||||
|
||||
Slices: (A) store + `BadgeData` + system for core and credits +
|
||||
chokepoint registration; (B) consumer cutover — Vue partition query,
|
||||
legacy draw cache, trigger/`VueNodeData.badges` deletions, legacy
|
||||
surface shim; extension-facing deprecation notes. Independent of the
|
||||
pending `nodeDataStore` extraction and lands before it, shrinking its
|
||||
Decision 4/6 scope (one less `VueNodeData` field, one less property
|
||||
handler). `PartnerNodesList`'s `find(isCreditsBadge)` migrates to a
|
||||
store query by kind.
|
||||
139
docs/architecture/node-data-store.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Node Data Store
|
||||
|
||||
Date: 2026-07-05
|
||||
Status: Draft (design interview in progress; follow-up to the
|
||||
[link topology store](link-topology-store.md) and
|
||||
[reroute chain store](reroute-chain-store.md))
|
||||
|
||||
Design record for extracting the remaining Node-owned components into a
|
||||
dedicated store per [ADR 0008](../adr/0008-entity-component-system.md),
|
||||
eliminating the `VueNodeData` mirror and most of
|
||||
`src/composables/graph/useGraphNodeManager.ts`.
|
||||
|
||||
## Decision 1: One store, one plain state object per node
|
||||
|
||||
`nodeDataStore` holds a single plain `NodeState` object per node,
|
||||
registered by reference with proxy-returning registration (the
|
||||
`BaseWidget` pattern, [reroute store Decision 4](reroute-chain-store.md)).
|
||||
ADR 0008's Node component rows (`NodeVisual`, `Execution`, ...) become
|
||||
field groupings inside `NodeState`, not separate records or stores.
|
||||
|
||||
Buckets are root-graph-scoped (`rootGraph.id`), keyed by `NodeId`.
|
||||
Node-id uniqueness across sibling subgraph definitions is already
|
||||
guaranteed by the load-time dedup pass
|
||||
(`src/lib/litegraph/src/subgraph/subgraphDeduplication.ts`).
|
||||
|
||||
## Decision 2: Field set — what is NodeState, what is elsewhere
|
||||
|
||||
```
|
||||
NodeState {
|
||||
id: NodeId
|
||||
graphId: UUID // owning (sub)graph — partitioning + locator ids
|
||||
type: string // identity, with apiNode?: boolean
|
||||
title: string
|
||||
titleMode?: TitleMode
|
||||
mode: number
|
||||
flags: { collapsed?, pinned?, ghost? }
|
||||
color?: string
|
||||
bgcolor?: string
|
||||
shape?: number
|
||||
resizable?: boolean
|
||||
showAdvanced?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Excluded — owned or derived elsewhere; referencing them here would be a
|
||||
mirror (the hard constraint of this phase):
|
||||
|
||||
| Field | Owner |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `selected` | `canvasStore.selectedNodeIds` (already what `LGraphNode.vue` reads) |
|
||||
| `executing` | `executionStore` via `useNodeExecutionState` (already what Vue reads) |
|
||||
| `hasErrors` | derived from `executionErrorStore` / missing-model/media stores; `node.has_errors` stays a legacy-canvas class field written by `useNodeErrorFlagSync` |
|
||||
| position / size / z | `layoutStore` |
|
||||
| widget values / order | `widgetValueStore` |
|
||||
| input link connectivity | `linkStore` (`getInputSlotLink` / `isInputSlotConnected`) |
|
||||
| `badges` | `nodeBadgeStore` — plain `BadgeData` rows written by a badge system; see [Node Badge Store](node-badge-store.md) |
|
||||
| `inputs` / `outputs` | deferred — see Decision 3 |
|
||||
|
||||
`VueNodeData.selected` and `.executing` are dead fields today (no
|
||||
production consumer reads them); they are deleted, not migrated.
|
||||
|
||||
## Decision 3: Slot arrays deferred; `inputs[].link` readers migrate now
|
||||
|
||||
`NodeInputSlot` / `NodeOutputSlot` are class instances with methods —
|
||||
Slot entity extraction (ADR 0008 `SlotIdentity` etc.) is its own future
|
||||
phase. The slot arrays stay class-side, keeping the `shallowReactive`
|
||||
graft for renderer reactivity.
|
||||
|
||||
What this phase does remove is the last `inputs[].link` dependency: the
|
||||
three remaining readers (`nodeDataUtils.linkedWidgetedInputs` used by
|
||||
`NodeSlots`, and `usePartitionedBadges`' badge computed plus its
|
||||
exported `trackNodePrice`) move to `linkStore.isInputSlotConnected`
|
||||
queries — presence is all any of them needed — which deletes the
|
||||
`node:slot-links:changed` → `refreshNodeInputs` reprojection in
|
||||
`useGraphNodeManager`, the dead `node:slot-errors:changed` handler
|
||||
(zero emitters repo-wide), and the node-removal refresh-all loop.
|
||||
Shipped ahead of the store itself (2026-07-05).
|
||||
|
||||
## Decision 4: Renderer consumes the NodeState proxy, `VueNodeData` dies
|
||||
|
||||
`GraphCanvas` iterates the store's bucket for the active graph (filtered
|
||||
by `NodeState.graphId`) and passes the reactive `NodeState` proxy down
|
||||
the existing prop-drilling path (`LGraphNode` → `NodeHeader` /
|
||||
`NodeSlots` / `NodeContent` / `NodeWidgets`). Children read proxy fields
|
||||
directly; Vue tracks the store state, so the per-property
|
||||
`node:property:changed` → snapshot-rewrite handlers in
|
||||
`useGraphNodeManager` are deleted wholesale.
|
||||
|
||||
Slot arrays reach `NodeSlots` via the existing live-node access
|
||||
(`getNodeByLocatorId`), not through `NodeState`.
|
||||
|
||||
`LGraphNodePreview` constructs a synthetic `NodeState` (as it does a
|
||||
synthetic `VueNodeData` today). `AppModeWidgetList` stops calling
|
||||
`extractVueNodeData` and reads the registered `NodeState` + live node.
|
||||
|
||||
## Decision 5: Registration lifecycle and class adoption
|
||||
|
||||
Follows the shipped trio convention (`LLink` / `Reroute`):
|
||||
|
||||
- `LGraphNode` constructs its `_state: NodeState` at instantiation;
|
||||
`registerNodeState(graph, node)` inserts it by reference and the class
|
||||
adopts the returned reactive proxy; `node._graphId` (root id) is the
|
||||
registration-ownership marker.
|
||||
- Chokepoints: `LGraph.add` / `LGraph.remove` (the canonical sites),
|
||||
`unregisterAllNodeStates(graph)` on graph `clear()`, identity-checked
|
||||
delete (`toRaw` compare) so only the registered state vacates its key.
|
||||
- Class fields become accessors reading through `_state`.
|
||||
`LGraphNodeProperties`' instrumented descriptors keep their
|
||||
get/set + `node:property:changed` emission but store the value in
|
||||
`_state` instead of a closure — trigger consumers (minimap,
|
||||
`useErrorClearingHooks`) keep working unchanged.
|
||||
- Serialization is unaffected: `serialize()` reads the same properties
|
||||
through the accessors.
|
||||
|
||||
## Decision 6: What remains of useGraphNodeManager
|
||||
|
||||
Deleted: `extractVueNodeData`, the `vueNodeData` map, all
|
||||
`node:property:changed` handlers, `syncWithGraph`, `getNode()`
|
||||
(consumers use `graph.getNodeById`), the `node:slot-links:changed`
|
||||
handler (Decision 3).
|
||||
|
||||
Remaining renderer-side lifecycle, slimmed into `useVueNodeLifecycle`
|
||||
(or a small successor):
|
||||
|
||||
- layoutStore seeding on node add/remove (`createNode`/`deleteNode`
|
||||
layout mutations, including the `onAfterGraphConfigured` deferral) —
|
||||
layout is renderer policy, not entity data.
|
||||
- `node:slot-label:changed` slot-array reprojection — dies with the
|
||||
Slot extraction phase.
|
||||
|
||||
## Scope
|
||||
|
||||
Covers node shell state, the `VueNodeData` deletion, and the
|
||||
`inputs[].link` reader migration. Out of scope: Slot entity extraction,
|
||||
`Properties` (`properties` / `properties_info`) and `NodeType` metadata
|
||||
beyond `type`/`apiNode` (`category`, `nodeData`, `description` remain on
|
||||
the class/constructor), badges, `WidgetContainer` (already owned by
|
||||
`widgetValueStore`), and command-pattern mutators (future work per
|
||||
ADR 0003/0008).
|
||||
250
docs/architecture/output-slot-connectivity.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Output Slot Connectivity
|
||||
|
||||
Date: 2026-07-06
|
||||
Status: Accepted (follow-up to the
|
||||
[link topology store](link-topology-store.md); the minimal, non-breaking
|
||||
slice of the deferred `SlotConnection` component work in the
|
||||
[ECS migration plan](ecs-migration-plan.md))
|
||||
|
||||
Design record for answering "is this output slot connected, and by what
|
||||
links?" from `linkStore` instead of the `output.links[]` mirror. It is
|
||||
the output-side counterpart to the input-side migration shipped in
|
||||
[node-data-store Decision 3](node-data-store.md) (#13455). Wiring it lets
|
||||
`SlotConnectionDot` show connected state and removes the last renderer
|
||||
dependency on `output.links[]`.
|
||||
|
||||
This phase does not extract a Slot entity, add a store, or delete any
|
||||
mirror field. Those stay in the deferred `SlotConnection` phase. What it
|
||||
does is the smallest change that clears the reader debt and lets the
|
||||
output dot show connection state.
|
||||
|
||||
## Motivation
|
||||
|
||||
`SlotConnectionDot.vue` colors slots by type only. It carries a
|
||||
`//TODO Support connected/disconnected colors?`. `InputSlot` and
|
||||
`OutputSlot` already declare `connected` / `compatible` props and apply
|
||||
the `lg-slot--connected` / `lg-slot--compatible` classes, but
|
||||
`NodeSlots.vue` passes neither, so the styling path is built but unwired.
|
||||
|
||||
Input connectivity is already answerable through
|
||||
`linkStore.isInputSlotConnected` (shipped in #13455). The matching output
|
||||
query does not exist yet, so the output dot cannot be wired and
|
||||
`output.links[]` stays the only source. This phase adds that query and
|
||||
consumes it.
|
||||
|
||||
## Decision 1: Extend `linkStore`, do not add a store or a Slot entity
|
||||
|
||||
Output connectivity is link topology: the mirror image of the input side
|
||||
the store already owns. It belongs in `linkStore`, not a new `slotStore`.
|
||||
A dedicated Slot store (plain `SlotIdentity` / `SlotVisual` rows, retiring
|
||||
the slot class instances) is the full `SlotConnection` phase and is out
|
||||
of scope here. Introducing it now would be premature per the
|
||||
node-data-store record, which keeps the slot arrays class-side.
|
||||
|
||||
This phase adds read-only accessors over state the store already holds.
|
||||
It needs no new plain-data component, registration trio, chokepoint, or
|
||||
class adoption, though each sibling store required all four.
|
||||
|
||||
## Decision 2: Output connectivity is a derived reverse index
|
||||
|
||||
The store already exposes `graphTopologies(graphId)` over every
|
||||
registered `LinkTopology`. "Which links leave output slot _(node, slot)_"
|
||||
is a reverse index over origin endpoints. This is the same pattern
|
||||
`rerouteStore` uses for link membership
|
||||
([reroute store](reroute-chain-store.md)): a per-graph cached `computed`
|
||||
that Vue invalidates when link state changes. Nothing is stored, so the
|
||||
membership is always derived and cannot drift from the topology.
|
||||
|
||||
```ts
|
||||
type OriginIndex = Map<
|
||||
OriginSlotKey /* `${originNodeId}:${originSlot}` */,
|
||||
Set<LinkTopology>
|
||||
>
|
||||
|
||||
const outputIndexes = new Map<UUID, ComputedRef<OriginIndex>>()
|
||||
|
||||
function outputIndex(graphId: UUID): ComputedRef<OriginIndex> {
|
||||
const existing = outputIndexes.get(graphId)
|
||||
if (existing) return existing
|
||||
const next = computed(() => {
|
||||
const index: OriginIndex = new Map()
|
||||
for (const t of graphTopologies(graphId)) {
|
||||
if (t.originNodeId === UNASSIGNED_NODE_ID) continue
|
||||
const key = originKey(t.originNodeId, t.originSlot)
|
||||
const links = index.get(key) ?? new Set<LinkTopology>()
|
||||
links.add(t)
|
||||
index.set(key, links)
|
||||
}
|
||||
return index
|
||||
})
|
||||
outputIndexes.set(graphId, next)
|
||||
return next
|
||||
}
|
||||
```
|
||||
|
||||
The index spans both collections that `graphTopologies` yields, the keyed
|
||||
targets and the unkeyed side set, so a floating link with an assigned
|
||||
origin (one endpoint only) still reports its output as connected. A
|
||||
`SUBGRAPH_INPUT_ID` origin indexes like any other id.
|
||||
|
||||
## Decision 3: Two public queries, mirroring the input pair
|
||||
|
||||
```ts
|
||||
isOutputSlotConnected(graphId, nodeId, slot): boolean
|
||||
getOutputSlotLinks(graphId, nodeId, slot): ReadonlySet<LinkTopology>
|
||||
```
|
||||
|
||||
These match `isInputSlotConnected` / `getInputSlotLink` in name and shape.
|
||||
The input side returns a single `LinkTopology`, since at most one link
|
||||
targets an input; the output side returns a set, since an output fans out
|
||||
to many. The set holds topologies rather than bare `LinkId`s: floating
|
||||
links draw their ids from a separate counter (`_lastFloatingLinkId`), so
|
||||
an id alone cannot be resolved safely through `graph.links` — a floating
|
||||
id can collide with an unrelated regular link. Topologies carry the
|
||||
endpoints most readers want and make floating links identifiable
|
||||
(`targetNodeId === UNASSIGNED_NODE_ID`) without resolution.
|
||||
|
||||
## Decision 4: Migrate readers incrementally, keep the mirror written by hand
|
||||
|
||||
This follows the #13455 discipline for `input.link`: move the readers to
|
||||
the store, leave the field in place, and keep writing it at the
|
||||
chokepoints.
|
||||
|
||||
`output.links[]` has roughly 200 read sites but only about 12 write
|
||||
sites, all in `LGraphNode`, `LGraph`, and the subgraph paths. Most of the
|
||||
reads are litegraph-internal graph algorithms such as traversal, dedup,
|
||||
and serialization. Those can keep reading the mirror indefinitely; they
|
||||
are not renderer policy and nothing blocks on them.
|
||||
|
||||
Migrated readers: the output dot's connected state (`NodeSlots`), the
|
||||
minimap link extraction (`AbstractMinimapDataSource`), the drag-start
|
||||
disconnect check (`useSlotLinkInteraction`, where one store query replaces
|
||||
a mirror read plus a `slotFloatingLinks` scan), widget value propagation
|
||||
(`widgetValuePropagation`), and matchType link revalidation
|
||||
(`dynamicWidgets.changeOutputType`).
|
||||
|
||||
## Decision 6: Delete the mirrors (implemented; extended to `input.link`)
|
||||
|
||||
The runtime `output.links[]` field and all nine of its write sites are
|
||||
deleted. The same recipe has since been applied to `input.link`: the
|
||||
field is a deprecated warning getter, litegraph and app code read through
|
||||
the slotLinks input helpers (`inputHasLink`, `inputLinkId`, `inputLink`)
|
||||
or `node.isInputConnected` / `node.getInputLink`, serialization derives
|
||||
`inputs[].link` from the store, and the mirror-carried association
|
||||
shuffles were reworked — `fixLinkInputSlots` consumes the serialized
|
||||
graph data, dynamicWidgets' group rebuilds carry slot→link association
|
||||
in a module-scoped WeakMap refreshed from the store, and link
|
||||
deduplication selects survivors from the store registration (the
|
||||
`repairInputLinks` mirror repair is gone; the derived view cannot be
|
||||
wrong). The store is the single source; litegraph internals read through
|
||||
the pure helpers in `node/slotLinks.ts` (`outputHasLinks`,
|
||||
`outputLinkIds`, `outputLinks`), and `NodeOutputSlot.isConnected`,
|
||||
`serialize`, and `configure` derive from the store. Details:
|
||||
|
||||
- **Wire format unchanged.** `outputAsSerialisable` / `toJSON` emit the
|
||||
serialized `outputs[].links` array from the store, sorted ascending by
|
||||
id (a determinism choice — equal to push order for organically built
|
||||
graphs) and `null` when empty. `configure` fires its output
|
||||
`onConnectionsChange` callbacks from the serialized argument.
|
||||
- **Floating links are never returned by link queries.** They are
|
||||
reroute-chain scaffolding, named by `isFloatingTopology`
|
||||
(`src/types/linkTopology.ts`). `isOutputSlotConnected` /
|
||||
`getOutputSlotLinks` see fully-assigned links only, matching the
|
||||
mirror they replace; the one consumer with legacy floating-aware
|
||||
behavior (the slot-drag disconnect check) keeps its own
|
||||
`slotFloatingLinks` scan.
|
||||
- **Extension compat = deprecation telemetry, not compatibility.** A
|
||||
read-only prototype getter on `NodeOutputSlot` returns a fresh
|
||||
store-derived `LinkId[] | null` and fires `warnDeprecated`. Writes fire
|
||||
their own `warnDeprecated` naming the replacement APIs
|
||||
(`node.connect()` / `node.disconnectOutput()`) and are otherwise
|
||||
ignored — the store stays authoritative and legacy writers degrade
|
||||
gracefully instead of crashing. A bare accessor-only property would
|
||||
instead throw an unactionable `TypeError` in strict mode.
|
||||
`INodeOutputSlot` keeps `links` as `@deprecated readonly` so
|
||||
`'links' in slot` discriminants still compile and hold at runtime via
|
||||
the prototype. The mirror keys are non-enumerable: `{ ...slot }`,
|
||||
`Object.assign({}, slot)`, and `Object.keys(slot)` do not carry
|
||||
`link` / `links` (use the store queries to snapshot connectivity);
|
||||
`JSON.stringify` still emits them via `toJSON`. `addInput` / `addOutput`
|
||||
drop a stale `link` / `links` key from `extra_info` instead of letting
|
||||
`Object.assign` collide with the accessor — the store is not consulted
|
||||
or mutated by such values.
|
||||
- **Duck-typed slots are upgraded in place.** `_setConcreteSlots` writes
|
||||
the concrete `NodeInputSlot` / `NodeOutputSlot` wrappers back into
|
||||
`node.inputs` / `node.outputs`. The concrete classes resolve their slot
|
||||
index by identity (`inputs.indexOf(this)`), so a wrapper that is not
|
||||
the array entry would permanently read as disconnected. Plain-object
|
||||
slots pushed directly into the arrays are therefore upgraded at the
|
||||
next concretisation (`configure`, paste/convert paths, every canvas
|
||||
draw); until then their stale `link` value is dead data. Extensions
|
||||
should use `node.addInput()` / `node.addOutput()` instead of pushing
|
||||
literals.
|
||||
- **Serialized-data operators are untouched.** `linkFixer` (serialized
|
||||
branch), `migrateReroute`, and `unpackSubgraph`'s pre-configure strip
|
||||
operate on the wire format, which still carries `links`.
|
||||
- **Behavior changes, deliberate:** `PrimitiveNode.onLastDisconnect`
|
||||
fires on disconnect-all (the stale mirror previously suppressed it);
|
||||
`disconnectInput` passes `link_info.origin_slot` — not the old
|
||||
mirror-array index — as the OUTPUT slot in `onConnectionsChange`;
|
||||
serialization emits `null` where a lingering `[]` used to persist.
|
||||
|
||||
Extension migration map: presence → `node.isOutputConnected(slot)` /
|
||||
`slot.isConnected`; enumerate targets → `node.getOutputNodes(slot)`;
|
||||
mutate → `node.connect(...)` / `node.disconnectOutput(slot, target?)`.
|
||||
App/Vue code uses `useLinkStore().getOutputSlotLinks(...)` (reactive).
|
||||
|
||||
## Decision 5: Wire connected state into the dots (the payoff)
|
||||
|
||||
`NodeSlots.vue` passes `connected` to each slot:
|
||||
|
||||
- input: `linkStore.isInputSlotConnected(rootGraphId, nodeId, index)`
|
||||
(already available)
|
||||
- output: `linkStore.isOutputSlotConnected(rootGraphId, nodeId, index)`
|
||||
(Decision 3)
|
||||
|
||||
`InputSlot` and `OutputSlot` already forward `connected` to the
|
||||
`lg-slot--connected` class. `SlotConnectionDot` needs no prop of its own:
|
||||
the wrapper class is an ancestor styling hook
|
||||
(`.lg-slot--connected .slot-dot`), so the visual is one CSS rule away once
|
||||
the design-standards check (open question 3) picks it. Threading a
|
||||
`connected` prop into the dot before that would be plumbing with no
|
||||
consumer. `compatible` stays driven by the existing drag state
|
||||
(`useSlotLinkDragUIState`), which this phase leaves alone.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope: the derived output-side index, the two queries, the output-dot
|
||||
reader migration, and wiring `connected` from `NodeSlots` into
|
||||
`InputSlot` / `OutputSlot` (whose `lg-slot--connected` class reaches the
|
||||
dot via CSS).
|
||||
|
||||
Since implemented by Decision 6: `output.links` reader migration, write
|
||||
sites, and field deletion.
|
||||
|
||||
Out of scope, each a piece of the deferred `SlotConnection` phase:
|
||||
|
||||
- Slot entity extraction: `SlotIdentity`, `SlotVisual`, and retiring the
|
||||
`NodeInputSlot` / `NodeOutputSlot` class instances and their
|
||||
`shallowReactive` graft.
|
||||
- A `compatible`-state source beyond the current drag UI state.
|
||||
- Floating/regular link-id counter unification (mint floating ids from
|
||||
the shared `state.lastLinkId`) — only if the internal wart ever bites;
|
||||
nothing resolves store-returned ids against `graph.links` across the
|
||||
id spaces anymore.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Index granularity.** One `computed` per graph rebuilds the whole
|
||||
origin index on any link change in that graph, the same cost model as
|
||||
`rerouteStore`. If profiling on large graphs shows this is hot, the
|
||||
fallback is an incrementally maintained `Map` updated in `place` and
|
||||
`displace`, which is more code and carries drift risk. Start with the
|
||||
derived version and measure before optimizing.
|
||||
2. **Return type of `getOutputSlotLinks`.** Resolved: a set of
|
||||
`LinkTopology`, per Decision 3. Bare ids looked sufficient until the
|
||||
reader migration surfaced both the endpoint needs (minimap, widget
|
||||
value propagation) and the floating-id collision hazard.
|
||||
3. **Dot visual.** What "connected" looks like on the dot (fill, ring, or
|
||||
opacity) is a design-standards question rather than an architecture
|
||||
one. Check the Comfy Design Standards before implementing Decision 5.
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
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 } from '@/lib/litegraph/src/litegraph'
|
||||
import { getDomWidgetZIndex } from './domWidgetZIndex'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('getDomWidgetZIndex', () => {
|
||||
it('follows graph node ordering when node.order is stale', () => {
|
||||
const graph = new LGraph()
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
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 type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -45,12 +43,12 @@ const {
|
||||
isDraggable = false,
|
||||
hiddenFavoriteIndicator = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
host,
|
||||
enableEmptyState = false,
|
||||
tooltip
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
parents?: SubgraphNode[]
|
||||
host?: SubgraphNode
|
||||
node?: LGraphNode
|
||||
widgets: { widget: IBaseWidget; node: LGraphNode }[]
|
||||
showLocateButton?: boolean
|
||||
@@ -145,31 +143,6 @@ const { t } = useI18n()
|
||||
|
||||
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
|
||||
|
||||
function isWidgetShownOnParents(
|
||||
widgetNode: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): boolean {
|
||||
const source = widgetPromotedSource(widgetNode, widget)
|
||||
return parents.some((parent) => {
|
||||
if (source) {
|
||||
const widgetNodeId = widgetNode.id
|
||||
const interiorNodeId =
|
||||
String(widgetNode.id) === String(parent.id)
|
||||
? source.nodeId
|
||||
: widgetNodeId
|
||||
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: interiorNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
return isWidgetPromotedOnSubgraphNode(parent, {
|
||||
sourceNodeId: widgetNode.id,
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isEmpty = computed(() => widgets.value.length === 0)
|
||||
|
||||
const displayLabel = computed(
|
||||
@@ -256,7 +229,10 @@ function clearWidgetErrors(
|
||||
source.sourceWidgetName,
|
||||
source.sourceWidgetName,
|
||||
value,
|
||||
options
|
||||
{
|
||||
min: source.sourceWidget.options?.min,
|
||||
max: source.sourceWidget.options?.max
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -401,8 +377,7 @@ defineExpose({
|
||||
:is-draggable="isDraggable"
|
||||
:hidden-favorite-indicator="hiddenFavoriteIndicator"
|
||||
:show-node-name="showNodeName"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isWidgetShownOnParents(node, widget)"
|
||||
:host="host"
|
||||
@update:widget-value="handleWidgetValueUpdate(node, widget, $event)"
|
||||
@reset-to-default="handleWidgetReset(node, widget, $event)"
|
||||
/>
|
||||
|
||||
@@ -33,7 +33,7 @@ const captured: { rows: { node: LGraphNode; widget: IBaseWidget }[] } = {
|
||||
}
|
||||
|
||||
const SectionWidgetsStub = {
|
||||
props: ['widgets', 'node', 'parents'],
|
||||
props: ['widgets', 'node', 'host'],
|
||||
setup(props: Record<string, unknown>) {
|
||||
captured.rows = props.widgets as {
|
||||
node: LGraphNode
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { promotedInputWidgets } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
getWidgetName,
|
||||
isWidgetPromotedOnSubgraphNode,
|
||||
reorderSubgraphInputsByWidgetOrder
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
@@ -83,13 +82,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
|
||||
({ node: interiorNode, widget }) =>
|
||||
!isWidgetPromotedOnSubgraphNode(node, {
|
||||
sourceNodeId: interiorNode.id,
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: widget.name
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const parents = computed<SubgraphNode[]>(() => [node])
|
||||
|
||||
const searchedWidgetsList = shallowRef<NodeWidgetsList>(widgetsList.value)
|
||||
const isSearching = ref(false)
|
||||
|
||||
@@ -140,7 +137,7 @@ const label = computed(() => {
|
||||
:collapse="firstSectionCollapsed && !isSearching"
|
||||
:node
|
||||
:label
|
||||
:parents
|
||||
:host="node"
|
||||
:widgets="searchedWidgetsList"
|
||||
:is-draggable="!isSearching"
|
||||
:enable-empty-state="isSearching"
|
||||
@@ -164,7 +161,7 @@ const label = computed(() => {
|
||||
ref="advancedInputsSectionRef"
|
||||
v-model:collapse="advancedInputsCollapsed"
|
||||
:label="t('rightSidePanel.advancedInputs')"
|
||||
:parents="parents"
|
||||
:host="node"
|
||||
:widgets="advancedInputsWidgets"
|
||||
show-node-name
|
||||
class="border-b border-interface-stroke"
|
||||
|
||||
@@ -8,18 +8,21 @@ import { h } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { promoteWidget } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import WidgetActions from './WidgetActions.vue'
|
||||
|
||||
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn()
|
||||
}))
|
||||
const { mockGetInputSpecForWidget, mockIsFavorited, mockToggleFavorite } =
|
||||
vi.hoisted(() => ({
|
||||
mockGetInputSpecForWidget: vi.fn(),
|
||||
mockIsFavorited: vi.fn(),
|
||||
mockToggleFavorite: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
|
||||
demoteWidget: vi.fn(),
|
||||
promoteWidget: vi.fn(),
|
||||
isLinkedPromotion: vi.fn(() => false)
|
||||
promoteWidget: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeDefStore', () => ({
|
||||
@@ -36,8 +39,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock('@/stores/workspace/favoritedWidgetsStore', () => ({
|
||||
useFavoritedWidgetsStore: () => ({
|
||||
isFavorited: vi.fn().mockReturnValue(false),
|
||||
toggleFavorite: vi.fn()
|
||||
isFavorited: mockIsFavorited,
|
||||
toggleFavorite: mockToggleFavorite
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -62,7 +65,6 @@ const i18n = createI18n({
|
||||
enterNewName: 'Enter new name'
|
||||
},
|
||||
rightSidePanel: {
|
||||
hideInput: 'Hide input',
|
||||
showInput: 'Show input',
|
||||
addFavorite: 'Favorite',
|
||||
removeFavorite: 'Unfavorite',
|
||||
@@ -76,6 +78,7 @@ describe('WidgetActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.resetAllMocks()
|
||||
mockIsFavorited.mockReturnValue(false)
|
||||
mockGetInputSpecForWidget.mockReturnValue({
|
||||
type: 'INT',
|
||||
default: 42
|
||||
@@ -205,4 +208,53 @@ describe('WidgetActions', () => {
|
||||
|
||||
expect(onResetToDefault).toHaveBeenCalledWith('option1')
|
||||
})
|
||||
|
||||
it('promotes the widget into the host when "Show input" is clicked', async () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode()
|
||||
const host = fromAny<SubgraphNode, unknown>({ id: 2 })
|
||||
|
||||
const { user } = renderWidgetActions(widget, node, { host })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Show input/ }))
|
||||
|
||||
expect(promoteWidget).toHaveBeenCalledWith(node, widget, [host])
|
||||
})
|
||||
|
||||
it('does not offer "Show input" without a host', () => {
|
||||
renderWidgetActions(createMockWidget(), createMockNode())
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Show input/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not offer "Show input" when the host input is already linked', () => {
|
||||
const widget = createMockWidget()
|
||||
const node = fromAny<LGraphNode, unknown>({
|
||||
id: 1,
|
||||
type: 'TestNode',
|
||||
rootGraph: { id: 'graph-test' },
|
||||
isSubgraphNode: () => true,
|
||||
getSlotFromWidget: () => ({ widgetId: 'graph-test:1:test_widget' })
|
||||
})
|
||||
const host = fromAny<SubgraphNode, unknown>({ id: 2 })
|
||||
|
||||
renderWidgetActions(widget, node, { host })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Show input/ })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the favorite for the host node itself', async () => {
|
||||
const widget = createMockWidget()
|
||||
const node = createMockNode()
|
||||
|
||||
const { user } = renderWidgetActions(widget, node)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Favorite/ }))
|
||||
|
||||
expect(mockToggleFavorite).toHaveBeenCalledWith(node, 'test_widget')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,33 +5,21 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MoreButton from '@/components/button/MoreButton.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { widgetPromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
isLinkedPromotion,
|
||||
promoteWidget
|
||||
} from '@/core/graph/subgraph/promotionUtils'
|
||||
import { inputForWidget } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import { promoteWidget } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
|
||||
import type { WidgetValue } from '@/utils/widgetUtil'
|
||||
|
||||
const {
|
||||
widget,
|
||||
node,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
} = defineProps<{
|
||||
const { widget, node, host } = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
host?: SubgraphNode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -40,24 +28,17 @@ const emit = defineEmits<{
|
||||
|
||||
const label = defineModel<string>('label', { required: true })
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const isLinked = computed(() => {
|
||||
if (!node.isSubgraphNode()) return false
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (!source) return false
|
||||
return isLinkedPromotion(node, source.nodeId, source.widgetName)
|
||||
return inputForWidget(node, widget)?.widgetId != null
|
||||
})
|
||||
const canToggleVisibility = computed(() => hasParents.value && !isLinked.value)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
const canShowInput = computed(() => host != null && !isLinked.value)
|
||||
const isFavorited = computed(() =>
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode.value, widget.name)
|
||||
favoritedWidgetsStore.isFavorited(node, widget.name)
|
||||
)
|
||||
|
||||
const inputSpec = computed(() =>
|
||||
@@ -85,33 +66,13 @@ async function handleRename() {
|
||||
if (newLabel !== null) label.value = newLabel
|
||||
}
|
||||
|
||||
function handleHideInput() {
|
||||
if (!parents?.length) return
|
||||
|
||||
const source = widgetPromotedSource(node, widget)
|
||||
if (source) {
|
||||
const currentNodeId = node.id
|
||||
for (const parent of parents) {
|
||||
const sourceNodeId =
|
||||
String(node.id) === String(parent.id) ? source.nodeId : currentNodeId
|
||||
demotePromotedInput(parent, {
|
||||
sourceNodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
}
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
} else {
|
||||
demoteWidget(node, widget, parents)
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowInput() {
|
||||
if (!parents?.length) return
|
||||
promoteWidget(node, widget, parents)
|
||||
if (!host) return
|
||||
promoteWidget(node, widget, [host])
|
||||
}
|
||||
|
||||
function handleToggleFavorite() {
|
||||
favoritedWidgetsStore.toggleFavorite(favoriteNode.value, widget.name)
|
||||
favoritedWidgetsStore.toggleFavorite(node, widget.name)
|
||||
}
|
||||
|
||||
function handleResetToDefault() {
|
||||
@@ -143,26 +104,19 @@ function handleResetToDefault() {
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="canToggleVisibility"
|
||||
v-if="canShowInput"
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
class="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-sm transition-all active:scale-95"
|
||||
@click="
|
||||
() => {
|
||||
if (isShownOnParents) handleHideInput()
|
||||
else handleShowInput()
|
||||
handleShowInput()
|
||||
close()
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="isShownOnParents">
|
||||
<i class="icon-[lucide--eye-off] size-4" />
|
||||
<span>{{ t('rightSidePanel.hideInput') }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</template>
|
||||
<i class="icon-[lucide--eye] size-4" />
|
||||
<span>{{ t('rightSidePanel.showInput') }}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -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'
|
||||
@@ -37,8 +40,7 @@ const {
|
||||
hiddenFavoriteIndicator = false,
|
||||
hiddenWidgetActions = false,
|
||||
showNodeName = false,
|
||||
parents = [],
|
||||
isShownOnParents = false
|
||||
host
|
||||
} = defineProps<{
|
||||
widget: IBaseWidget
|
||||
node: LGraphNode
|
||||
@@ -46,8 +48,7 @@ const {
|
||||
hiddenFavoriteIndicator?: boolean
|
||||
hiddenWidgetActions?: boolean
|
||||
showNodeName?: boolean
|
||||
parents?: SubgraphNode[]
|
||||
isShownOnParents?: boolean
|
||||
host?: SubgraphNode
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -61,6 +62,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 +71,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 +93,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),
|
||||
@@ -111,10 +113,7 @@ const displayNodeName = computed((): string | null => {
|
||||
})
|
||||
})
|
||||
|
||||
const hasParents = computed(() => parents?.length > 0)
|
||||
const favoriteNode = computed(() =>
|
||||
isShownOnParents && hasParents.value ? parents[0] : node
|
||||
)
|
||||
const hasHost = computed(() => host != null)
|
||||
|
||||
const widgetValue = computed({
|
||||
get: () => widget.value,
|
||||
@@ -134,7 +133,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
|
||||
const trimmedLabel = newValue.trim()
|
||||
|
||||
const success = renameWidget(widget, node, trimmedLabel, parents)
|
||||
const success = renameWidget(widget, node, trimmedLabel)
|
||||
|
||||
if (success) {
|
||||
canvasStore.canvas?.setDirty(true)
|
||||
@@ -176,7 +175,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="(showNodeName || hasParents) && displayNodeName"
|
||||
v-if="(showNodeName || hasHost) && displayNodeName"
|
||||
class="mx-1 my-0 min-w-10 flex-1 truncate p-0 text-right text-xs text-muted-foreground"
|
||||
>
|
||||
{{ displayNodeName }}
|
||||
@@ -189,8 +188,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
v-model:label="displayLabel"
|
||||
:widget="widget"
|
||||
:node="node"
|
||||
:parents="parents"
|
||||
:is-shown-on-parents="isShownOnParents"
|
||||
:host="host"
|
||||
@reset-to-default="emit('resetToDefault', $event)"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +197,7 @@ const displayLabel = customRef((track, trigger) => {
|
||||
<div
|
||||
v-if="
|
||||
!hiddenFavoriteIndicator &&
|
||||
favoritedWidgetsStore.isFavorited(favoriteNode, widget.name)
|
||||
favoritedWidgetsStore.isFavorited(node, widget.name)
|
||||
"
|
||||
class="pointer-events-none relative z-2"
|
||||
>
|
||||
|
||||
@@ -7,10 +7,8 @@ import DraggableList from '@/components/common/DraggableList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import {
|
||||
demotePromotedInput,
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
isLinkedPromotion,
|
||||
isRecommendedWidget,
|
||||
promoteWidget,
|
||||
pruneDisconnected,
|
||||
@@ -23,7 +21,10 @@ import {
|
||||
import type { PromotedSource } from '@/core/graph/subgraph/promotedInputWidget'
|
||||
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
ISubgraphInput
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
@@ -32,7 +33,6 @@ import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
import { UNASSIGNED_NODE_ID } from '@/types/nodeId'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
@@ -40,7 +40,7 @@ import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
|
||||
type PromotedRow = {
|
||||
kind: 'promoted'
|
||||
node: LGraphNode
|
||||
input: INodeInputSlot
|
||||
input: INodeInputSlot & Partial<ISubgraphInput>
|
||||
widget: IBaseWidget
|
||||
}
|
||||
type PreviewRow = {
|
||||
@@ -248,14 +248,7 @@ function rowDisplayName(row: ActiveRow): string {
|
||||
}
|
||||
|
||||
function isRowLinked(row: ActiveRow): boolean {
|
||||
if (row.kind !== 'promoted') return false
|
||||
if (row.node.id === UNASSIGNED_NODE_ID) return true
|
||||
const source = promotedRowSource(row)
|
||||
return (
|
||||
!!activeNode.value &&
|
||||
!!source &&
|
||||
isLinkedPromotion(activeNode.value, String(row.node.id), source.widgetName)
|
||||
)
|
||||
return row.kind === 'promoted' && row.input.widgetId != null
|
||||
}
|
||||
|
||||
function promotedRowKey(row: PromotedRow): string {
|
||||
@@ -276,12 +269,11 @@ function demoteRow(row: ActiveRow) {
|
||||
const subgraphNode = activeNode.value
|
||||
if (!subgraphNode) return
|
||||
if (row.kind === 'promoted') {
|
||||
const source = promotedRowSource(row)
|
||||
if (source) {
|
||||
demotePromotedInput(subgraphNode, {
|
||||
sourceNodeId: source.nodeId,
|
||||
sourceWidgetName: source.widgetName
|
||||
})
|
||||
const linkedInput = row.input._subgraphSlot
|
||||
if (linkedInput) {
|
||||
const inputIndex = subgraphNode.inputs.indexOf(row.input)
|
||||
if (subgraphNode.isInputConnected(inputIndex)) linkedInput.disconnect()
|
||||
else subgraphNode.subgraph.removeInput(linkedInput)
|
||||
}
|
||||
refreshPromotedWidgetRendering()
|
||||
return
|
||||
|
||||
@@ -67,10 +67,10 @@ describe('Node Reactivity', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const onValueChange = vi.fn()
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT
|
||||
})
|
||||
const upstream = new LGraphNode('upstream')
|
||||
upstream.addOutput('out', 'INT')
|
||||
graph.add(upstream)
|
||||
upstream.connect(0, node, 0)
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(widgetId(graph.id, node.id, 'testnum'))
|
||||
@@ -87,7 +87,7 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Widget slotMetadata reactivity on link disconnect', () => {
|
||||
describe('Widget input link reactivity', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -96,10 +96,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,81 +110,14 @@ 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)
|
||||
})
|
||||
|
||||
it('updates slotMetadata.linked to false after link disconnect event', async () => {
|
||||
const { graph, node } = createWidgetInputGraph()
|
||||
const { vueNodeData } = useGraphNodeManager(graph)
|
||||
|
||||
const nodeData = vueNodeData.get(node.id)
|
||||
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
||||
|
||||
// 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,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
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(nodeData?.inputs?.[0]?.widget?.name).toBe('prompt')
|
||||
expect(nodeData?.inputs?.[0]?.link).not.toBeNull()
|
||||
})
|
||||
|
||||
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
|
||||
@@ -205,15 +136,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,39 +156,21 @@ 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)
|
||||
|
||||
it('clears stale slotMetadata when input no longer matches widget', 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)
|
||||
|
||||
node.inputs[0].name = 'other'
|
||||
node.inputs[0].widget = { name: 'other' }
|
||||
node.inputs[0].link = null
|
||||
|
||||
graph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: 0,
|
||||
connected: false,
|
||||
linkId: 42
|
||||
expect(valueState?.name).toBe('value')
|
||||
expect(valueState?.value).toBe('hello')
|
||||
expect(renderState).toMatchObject({
|
||||
hasLayoutSize: false,
|
||||
isDOMWidget: false
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(widgetData.slotMetadata).toBeUndefined()
|
||||
expect(renderState).not.toHaveProperty('sourceWidgetName')
|
||||
expect(subgraphNode.inputs[0].widget?.name).toBe('value')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -368,15 +277,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 +312,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 +354,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 +376,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,86 +11,16 @@ 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,
|
||||
LGraphBadge,
|
||||
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
|
||||
}
|
||||
|
||||
export interface VueNodeData {
|
||||
executing: boolean
|
||||
@@ -108,7 +30,6 @@ export interface VueNodeData {
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: Badges
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
@@ -124,260 +45,24 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
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>()
|
||||
|
||||
function makeReactiveNodeArrays(node: LGraphNode): {
|
||||
inputs: INodeInputSlot[]
|
||||
outputs: INodeOutputSlot[]
|
||||
} {
|
||||
const existingWidgetsDescriptor = Object.getOwnPropertyDescriptor(
|
||||
node,
|
||||
'widgets'
|
||||
)
|
||||
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 +91,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
enumerable: true
|
||||
})
|
||||
}
|
||||
|
||||
const reactiveInputs = shallowReactive<INodeInputSlot[]>(node.inputs ?? [])
|
||||
Object.defineProperty(node, 'inputs', {
|
||||
get() {
|
||||
@@ -417,6 +103,7 @@ export function extractVueNodeData(node: LGraphNode): VueNodeData {
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
|
||||
const reactiveOutputs = shallowReactive<INodeOutputSlot[]>(node.outputs ?? [])
|
||||
Object.defineProperty(node, 'outputs', {
|
||||
get() {
|
||||
@@ -429,19 +116,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 +133,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 +140,12 @@ 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,
|
||||
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 +156,17 @@ 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 nodeRef = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +174,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 +233,12 @@ 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)
|
||||
originalCallback?.(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates cleanup function for event listeners and state
|
||||
*/
|
||||
const createCleanupFunction = (
|
||||
originalOnNodeAdded: ((node: LGraphNode) => void) | undefined,
|
||||
originalOnNodeRemoved: ((node: LGraphNode) => void) | undefined,
|
||||
@@ -620,7 +246,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 +255,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)
|
||||
}
|
||||
@@ -662,7 +284,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
)
|
||||
|
||||
const triggerHandlers: {
|
||||
[K in LGraphTriggerAction]: (event: LGraphTriggerParam<K>) => void
|
||||
[K in Extract<
|
||||
LGraphTriggerAction,
|
||||
'node:property:changed' | 'node:slot-label:changed'
|
||||
>]: (event: LGraphTriggerParam<K>) => void
|
||||
} = {
|
||||
'node:property:changed': (propertyEvent) => {
|
||||
const nodeId = toNodeId(propertyEvent.nodeId)
|
||||
@@ -751,38 +376,20 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'badges':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
badges: propertyEvent.newValue as Badges
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
'node:slot-errors:changed': (slotErrorsEvent) => {
|
||||
refreshNodeSlots(toNodeId(slotErrorsEvent.nodeId))
|
||||
},
|
||||
'node:slot-links:changed': (slotLinksEvent) => {
|
||||
if (slotLinksEvent.slotType === NodeSlotType.INPUT) {
|
||||
refreshNodeSlots(toNodeId(slotLinksEvent.nodeId))
|
||||
}
|
||||
},
|
||||
'node:slot-label:changed': (slotLabelEvent) => {
|
||||
const nodeId = toNodeId(slotLabelEvent.nodeId)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,22 +398,14 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
case 'node:property:changed':
|
||||
triggerHandlers['node:property:changed'](event)
|
||||
break
|
||||
case 'node:slot-errors:changed':
|
||||
triggerHandlers['node:slot-errors:changed'](event)
|
||||
break
|
||||
case 'node:slot-links:changed':
|
||||
triggerHandlers['node:slot-links:changed'](event)
|
||||
break
|
||||
case 'node:slot-label:changed':
|
||||
triggerHandlers['node:slot-label:changed'](event)
|
||||
break
|
||||
}
|
||||
|
||||
// Chain to original handler
|
||||
originalOnTrigger?.(event)
|
||||
}
|
||||
|
||||
// Initialize state
|
||||
syncWithGraph()
|
||||
|
||||
return createCleanupFunction(
|
||||
@@ -817,10 +416,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
|
||||
|
||||
@@ -1,47 +1,27 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import { BadgePosition, LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import {
|
||||
bumpSubgraphCreditsRevision,
|
||||
startBadgeSystem
|
||||
} from '@/systems/badgeSystem'
|
||||
import { resolveNode } from '@/utils/litegraphUtil'
|
||||
|
||||
/**
|
||||
* Add LGraphBadge to LGraphNode based on settings.
|
||||
*
|
||||
* Following badges are added:
|
||||
* - Node ID badge
|
||||
* - Node source badge
|
||||
* - Node life cycle badge
|
||||
* - API node credits badge
|
||||
* Bootstraps the badge system: starts it against the live root graph,
|
||||
* forwards the subgraph structure events its store sources cannot
|
||||
* observe, and keeps the legacy canvas redrawing when badge sources
|
||||
* change. Dynamic-pricing recalculation wiring stays here because it
|
||||
* drives price evaluation, not badge storage.
|
||||
*/
|
||||
export const useNodeBadge = () => {
|
||||
const settingStore = useSettingStore()
|
||||
const extensionStore = useExtensionStore()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const priceBadge = usePriceBadge()
|
||||
|
||||
const nodeSourceBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
|
||||
) as NodeBadgeMode
|
||||
)
|
||||
|
||||
const showApiPricingBadge = computed(() =>
|
||||
settingStore.get('Comfy.NodeBadge.ShowApiPricing')
|
||||
@@ -49,9 +29,9 @@ export const useNodeBadge = () => {
|
||||
|
||||
watch(
|
||||
[
|
||||
nodeSourceBadgeMode,
|
||||
nodeIdBadgeMode,
|
||||
nodeLifeCycleBadgeMode,
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode'),
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode'),
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode'),
|
||||
showApiPricingBadge
|
||||
],
|
||||
() => {
|
||||
@@ -59,21 +39,9 @@ export const useNodeBadge = () => {
|
||||
}
|
||||
)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function badgeTextVisible(
|
||||
nodeDef: ComfyNodeDefImpl | null,
|
||||
badgeMode: NodeBadgeMode
|
||||
): boolean {
|
||||
return !(
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (extensionStore.isExtensionInstalled('Comfy.NodeBadge')) return
|
||||
|
||||
// TODO: Fix the composables and watchers being setup in onMounted
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
watch(
|
||||
@@ -84,140 +52,86 @@ export const useNodeBadge = () => {
|
||||
}
|
||||
)
|
||||
|
||||
function wirePricingRecalculation(node: LGraphNode): void {
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
if (!hasDynamicPricing) return
|
||||
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name ?? ''
|
||||
)
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
// Installs the widget listeners; the returned value is unused.
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes = pricingConfig?.depends_on?.input_groups ?? []
|
||||
if (relevantInputs.length === 0 && inputGroupPrefixes.length === 0) return
|
||||
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extensionStore.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
return new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
|
||||
? `#${node.id}`
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
|
||||
? (nodeDef?.nodeLifeCycleBadgeText ?? '')
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
|
||||
? (nodeDef?.nodeSource?.badgeText ?? '')
|
||||
: ''
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
// JSONata rules are dynamic if they depend on any widgets/inputs/input_groups
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
!!pricingConfig &&
|
||||
((pricingConfig.depends_on?.widgets?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.inputs?.length ?? 0) > 0 ||
|
||||
(pricingConfig.depends_on?.input_groups?.length ?? 0) > 0)
|
||||
|
||||
// Keep the existing widget-watch wiring ONLY to trigger redraws on widget change.
|
||||
// (We no longer rely on it to hold the current badge value.)
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
// Ensure watchers are installed; ignore the returned value.
|
||||
// (This call is what registers the widget listeners in most implementations.)
|
||||
computedWithWidgetWatch(() => 0)
|
||||
|
||||
// Hook into connection changes to trigger price recalculation
|
||||
// This handles both connect and disconnect in VueNodes mode
|
||||
const relevantInputs = pricingConfig?.depends_on?.inputs ?? []
|
||||
const inputGroupPrefixes =
|
||||
pricingConfig?.depends_on?.input_groups ?? []
|
||||
const hasRelevantInputs =
|
||||
relevantInputs.length > 0 || inputGroupPrefixes.length > 0
|
||||
|
||||
if (hasRelevantInputs) {
|
||||
const originalOnConnectionsChange = node.onConnectionsChange
|
||||
node.onConnectionsChange = function (
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
) {
|
||||
originalOnConnectionsChange?.call(
|
||||
this,
|
||||
type,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
link,
|
||||
ioSlot
|
||||
)
|
||||
// Only trigger if this input affects pricing
|
||||
const inputName = ioSlot?.name
|
||||
if (!inputName) return
|
||||
const isRelevantInput =
|
||||
relevantInputs.includes(inputName) ||
|
||||
inputGroupPrefixes.some((prefix) =>
|
||||
inputName.startsWith(prefix + '.')
|
||||
)
|
||||
if (isRelevantInput) {
|
||||
nodePricing.triggerPriceRecalculation(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lastLabel = nodePricing.getNodeDisplayPrice(node)
|
||||
let lastBadge = priceBadge.getCreditsBadge(lastLabel)
|
||||
|
||||
const creditsBadgeGetter: () => LGraphBadge = () => {
|
||||
const label = nodePricing.getNodeDisplayPrice(node)
|
||||
if (label !== lastLabel) {
|
||||
lastLabel = label
|
||||
lastBadge = priceBadge.getCreditsBadge(label)
|
||||
}
|
||||
return lastBadge
|
||||
}
|
||||
|
||||
node.badges.push(creditsBadgeGetter)
|
||||
wirePricingRecalculation(node)
|
||||
}
|
||||
},
|
||||
init() {
|
||||
startBadgeSystem({
|
||||
resolveGraphId: () => app.rootGraph.id,
|
||||
resolveNode: (nodeId) => resolveNode(nodeId, app.rootGraph)
|
||||
})
|
||||
app.canvas.canvas.addEventListener<'litegraph:set-graph'>(
|
||||
'litegraph:set-graph',
|
||||
() => {
|
||||
for (const node of app.canvas.graph?.nodes ?? [])
|
||||
priceBadge.updateSubgraphCredits(node)
|
||||
bumpSubgraphCreditsRevision()
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
)
|
||||
app.canvas.canvas.addEventListener<'subgraph-converted'>(
|
||||
'subgraph-converted',
|
||||
(e) => priceBadge.updateSubgraphCredits(e.detail.subgraphNode)
|
||||
() => {
|
||||
bumpSubgraphCreditsRevision()
|
||||
}
|
||||
)
|
||||
},
|
||||
afterConfigureGraph() {
|
||||
for (const node of app.canvas.graph?.nodes ?? [])
|
||||
priceBadge.updateSubgraphCredits(node)
|
||||
bumpSubgraphCreditsRevision()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -87,6 +87,7 @@ function createMockNodeWithPriceBadge(
|
||||
return Object.assign(baseNode, {
|
||||
widgets: mockWidgets,
|
||||
inputs: mockInputs,
|
||||
isInputConnected: (slot: number) => mockInputs[slot]?.link != null,
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: nodeTypeName,
|
||||
@@ -119,6 +120,7 @@ function createMockNode(
|
||||
return Object.assign(baseNode, {
|
||||
widgets,
|
||||
inputs,
|
||||
isInputConnected: (slot: number) => inputs[slot]?.link != null,
|
||||
constructor: { nodeData }
|
||||
})
|
||||
}
|
||||
@@ -173,6 +175,26 @@ describe('useNodePricing', () => {
|
||||
expect(price).toBe(creditsLabel(0.05))
|
||||
})
|
||||
|
||||
it('caches per signature so base and override reads both settle', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
'TestSignatureNode',
|
||||
priceBadge('{"type":"text","text": widgets.prompt}', [
|
||||
{ name: 'prompt', type: 'STRING' }
|
||||
]),
|
||||
[{ name: 'prompt', value: 'inner' }]
|
||||
)
|
||||
const overrides = new Map([['prompt', 'outer']])
|
||||
|
||||
getNodeDisplayPrice(node)
|
||||
getNodeDisplayPrice(node, overrides)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
expect(getNodeDisplayPrice(node)).toBe('inner')
|
||||
expect(getNodeDisplayPrice(node, overrides)).toBe('outer')
|
||||
expect(getNodeDisplayPrice(node)).toBe('inner')
|
||||
})
|
||||
|
||||
it('should handle FLOAT widget as number', async () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNodeWithPriceBadge(
|
||||
|
||||
@@ -251,8 +251,9 @@ const buildJsonataContext = (
|
||||
|
||||
const inputs: Record<string, { connected: boolean }> = {}
|
||||
for (const name of rule.depends_on.inputs) {
|
||||
const slot = node.inputs?.find((x: INodeInputSlot) => x.name === name)
|
||||
inputs[name] = { connected: slot?.link != null }
|
||||
const index =
|
||||
node.inputs?.findIndex((x: INodeInputSlot) => x.name === name) ?? -1
|
||||
inputs[name] = { connected: index !== -1 && node.isInputConnected(index) }
|
||||
}
|
||||
|
||||
// Count connected inputs per autogrow group
|
||||
@@ -261,8 +262,8 @@ const buildJsonataContext = (
|
||||
const prefix = groupName + '.'
|
||||
inputGroups[groupName] =
|
||||
node.inputs?.filter(
|
||||
(inp: INodeInputSlot) =>
|
||||
inp.name?.startsWith(prefix) && inp.link != null
|
||||
(inp: INodeInputSlot, index: number) =>
|
||||
inp.name?.startsWith(prefix) && node.isInputConnected(index)
|
||||
).length ?? 0
|
||||
}
|
||||
|
||||
@@ -469,21 +470,40 @@ const getNodeRevisionRef = (nodeId: NodeId): Ref<number> => {
|
||||
}
|
||||
|
||||
// WeakMaps avoid memory leaks when nodes are removed.
|
||||
type CacheEntry = { sig: string; label: string }
|
||||
type InflightEntry = { sig: string; promise: Promise<void> }
|
||||
|
||||
const cache = new WeakMap<LGraphNode, CacheEntry>()
|
||||
const desiredSig = new WeakMap<LGraphNode, string>()
|
||||
// Labels are cached per signature: a node can be read concurrently under
|
||||
// several signatures (its own widget values, and a SubgraphNode wrapper's
|
||||
// promoted overrides), and a single-slot cache would make those readers
|
||||
// evict each other's entry and re-schedule forever.
|
||||
const MAX_CACHED_SIGNATURES = 8
|
||||
const cache = new WeakMap<LGraphNode, Map<string, string>>()
|
||||
const inflight = new WeakMap<LGraphNode, InflightEntry>()
|
||||
|
||||
function nodeSigCache(node: LGraphNode): Map<string, string> {
|
||||
const existing = cache.get(node)
|
||||
if (existing) return existing
|
||||
const next = new Map<string, string>()
|
||||
cache.set(node, next)
|
||||
return next
|
||||
}
|
||||
|
||||
function cacheLabel(node: LGraphNode, sig: string, label: string): void {
|
||||
const sigCache = nodeSigCache(node)
|
||||
sigCache.delete(sig)
|
||||
sigCache.set(sig, label)
|
||||
for (const oldest of sigCache.keys()) {
|
||||
if (sigCache.size <= MAX_CACHED_SIGNATURES) break
|
||||
sigCache.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleEvaluation = (
|
||||
node: LGraphNode,
|
||||
rule: CompiledJsonataPricingRule,
|
||||
ctx: JsonataEvalContext,
|
||||
sig: string
|
||||
) => {
|
||||
desiredSig.set(node, sig)
|
||||
|
||||
const running = inflight.get(node)
|
||||
if (running && running.sig === sig) return
|
||||
|
||||
@@ -491,19 +511,11 @@ const scheduleEvaluation = (
|
||||
|
||||
const promise = Promise.resolve(rule._compiled.evaluate(ctx))
|
||||
.then((res) => {
|
||||
const label = formatPricingResult(res)
|
||||
|
||||
// Ignore stale results: if the node changed while we were evaluating,
|
||||
// desiredSig will no longer match.
|
||||
if (desiredSig.get(node) !== sig) return
|
||||
|
||||
cache.set(node, { sig, label })
|
||||
cacheLabel(node, sig, formatPricingResult(res))
|
||||
})
|
||||
.catch(() => {
|
||||
// Cache empty to avoid retry-spam for same signature
|
||||
if (desiredSig.get(node) === sig) {
|
||||
cache.set(node, { sig, label: '' })
|
||||
}
|
||||
cacheLabel(node, sig, '')
|
||||
})
|
||||
.finally(() => {
|
||||
const cur = inflight.get(node)
|
||||
@@ -574,15 +586,15 @@ export const useNodePricing = () => {
|
||||
const ctx = buildJsonataContext(node, rule, widgetOverrides)
|
||||
const sig = buildSignature(ctx, rule)
|
||||
|
||||
const cached = cache.get(node)
|
||||
if (cached && cached.sig === sig) {
|
||||
return cached.label
|
||||
}
|
||||
const sigCache = cache.get(node)
|
||||
const hit = sigCache?.get(sig)
|
||||
if (hit !== undefined) return hit
|
||||
|
||||
// Cache miss: start async evaluation.
|
||||
// Return last-known label (if any) to avoid flicker; otherwise return empty.
|
||||
// Return the last-known label (if any) to avoid flicker.
|
||||
scheduleEvaluation(node, rule, ctx, sig)
|
||||
return cached?.label ?? ''
|
||||
const labels = [...(sigCache?.values() ?? [])]
|
||||
return labels.at(-1) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { describe, expect, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { subgraphTest } from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphFixtures'
|
||||
|
||||
import { usePriceBadge } from '@/composables/node/usePriceBadge'
|
||||
|
||||
const getNodeDisplayPrice = vi.fn(
|
||||
(_node: LGraphNode, overrides?: ReadonlyMap<string, unknown>) =>
|
||||
String(overrides?.get('prompt') ?? 'missing override')
|
||||
)
|
||||
|
||||
vi.mock('@/composables/node/useNodePricing', () => ({
|
||||
useNodePricing: () => ({ getNodeDisplayPrice })
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
light_theme: false,
|
||||
colors: { litegraph_base: {} }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const { updateSubgraphCredits, getCreditsBadge } = usePriceBadge()
|
||||
|
||||
const mockNode = new LGraphNode('mock node')
|
||||
mockNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
|
||||
function getBadgeText(node: LGraphNode): string {
|
||||
const badge = node.badges[0]
|
||||
return (typeof badge === 'function' ? badge() : badge).text
|
||||
}
|
||||
|
||||
describe('subgraph pricing', () => {
|
||||
subgraphTest(
|
||||
'should not display badge for subgraphs without API nodes',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode } = subgraphWithNode
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
expect(subgraphNode.badges.length).toBe(0)
|
||||
}
|
||||
)
|
||||
subgraphTest(
|
||||
'should return the price of a single contained API node',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
subgraph.add(mockNode)
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
expect(subgraphNode.badges.length).toBe(1)
|
||||
expect(getBadgeText(subgraphNode)).toBe('$0.05/Run')
|
||||
}
|
||||
)
|
||||
subgraphTest(
|
||||
'should return the number of api nodes if more than one exists',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
for (let i = 0; i < 5; i++) subgraph.add(mockNode)
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
expect(subgraphNode.badges.length).toBe(1)
|
||||
expect(getBadgeText(subgraphNode)).toBe('Partner Nodes x 5')
|
||||
}
|
||||
)
|
||||
|
||||
subgraphTest(
|
||||
'uses promoted widget override from any matching internal link',
|
||||
({ subgraphWithNode }) => {
|
||||
const { subgraphNode, subgraph } = subgraphWithNode
|
||||
class ApiNode extends LGraphNode {
|
||||
static override nodeData = { name: 'ApiNode', api_node: true }
|
||||
}
|
||||
const apiNode = new ApiNode('api node')
|
||||
apiNode.badges = [getCreditsBadge('$0.05/Run')]
|
||||
const apiInput = apiNode.addInput('prompt', 'STRING')
|
||||
apiInput.widget = { name: 'prompt' }
|
||||
apiNode.addWidget('string', 'prompt', 'inner value', () => undefined, {})
|
||||
|
||||
const decoyNode = new LGraphNode('decoy node')
|
||||
const decoyInput = decoyNode.addInput('prompt', 'STRING')
|
||||
decoyInput.widget = { name: 'prompt' }
|
||||
decoyNode.addWidget(
|
||||
'string',
|
||||
'prompt',
|
||||
'decoy value',
|
||||
() => undefined,
|
||||
{}
|
||||
)
|
||||
|
||||
subgraph.add(decoyNode)
|
||||
subgraph.add(apiNode)
|
||||
subgraph.inputNode.slots[0].connect(decoyInput, decoyNode)
|
||||
subgraph.inputNode.slots[0].connect(apiInput, apiNode)
|
||||
subgraphNode._internalConfigureAfterSlots()
|
||||
const inputWidgetId = subgraphNode.inputs[0].widgetId
|
||||
if (!inputWidgetId) throw new Error('Missing promoted input widgetId')
|
||||
useWidgetValueStore().setValue(inputWidgetId, 'outer value')
|
||||
|
||||
updateSubgraphCredits(subgraphNode)
|
||||
|
||||
expect(getBadgeText(subgraphNode)).toBe('outer value')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { adjustColor } from '@/utils/colorUtil'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type LinkedWidgetInput = INodeInputSlot & {
|
||||
_subgraphSlot?: SubgraphInput
|
||||
}
|
||||
|
||||
const componentIconSvg = new Image()
|
||||
componentIconSvg.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
|
||||
|
||||
export const usePriceBadge = () => {
|
||||
const nodePricing = useNodePricing()
|
||||
|
||||
function updateSubgraphCredits(node: LGraphNode) {
|
||||
if (!node.isSubgraphNode()) return
|
||||
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
|
||||
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
|
||||
if (innerCreditsBadges.length > 1) {
|
||||
node.badges.push(
|
||||
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
|
||||
)
|
||||
} else if (innerCreditsBadges.length === 1) {
|
||||
const innerApiNodes = collectInnerApiNodes(node.subgraph)
|
||||
// When a single inner api node is the price source, swap its static
|
||||
// getter for a wrapper-aware one that resolves promoted widget values.
|
||||
if (innerApiNodes.length === 1) {
|
||||
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
|
||||
} else {
|
||||
node.badges.push(...innerCreditsBadges)
|
||||
}
|
||||
}
|
||||
const graph = node.graph
|
||||
if (!graph) return
|
||||
graph.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
oldValue: node.badges,
|
||||
newValue: node.badges
|
||||
})
|
||||
}
|
||||
|
||||
function collectCreditsBadges(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): (LGraphBadge | (() => LGraphBadge))[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
|
||||
for (const node of graph.nodes) {
|
||||
badges.push(
|
||||
...(node.isSubgraphNode()
|
||||
? collectCreditsBadges(node.subgraph, visited)
|
||||
: node.badges.filter((b) => isCreditsBadge(b)))
|
||||
)
|
||||
}
|
||||
return badges
|
||||
}
|
||||
|
||||
function collectInnerApiNodes(
|
||||
graph: LGraph,
|
||||
visited: Set<string> = new Set()
|
||||
): LGraphNode[] {
|
||||
if (visited.has(graph.id)) return []
|
||||
visited.add(graph.id)
|
||||
const apiNodes: LGraphNode[] = []
|
||||
for (const node of graph.nodes) {
|
||||
if (node.isSubgraphNode()) {
|
||||
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
|
||||
} else if (node.constructor?.nodeData?.api_node) {
|
||||
apiNodes.push(node)
|
||||
}
|
||||
}
|
||||
return apiNodes
|
||||
}
|
||||
|
||||
function buildWrapperAwarePriceBadge(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): () => LGraphBadge {
|
||||
return () =>
|
||||
getCreditsBadge(
|
||||
nodePricing.getNodeDisplayPrice(
|
||||
innerNode,
|
||||
collectPromotedOverrides(wrapper, innerNode)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function collectPromotedOverrides(
|
||||
wrapper: LGraphNode,
|
||||
innerNode: LGraphNode
|
||||
): ReadonlyMap<string, unknown> {
|
||||
const overrides = new Map<string, unknown>()
|
||||
if (!wrapper.isSubgraphNode()) return overrides
|
||||
|
||||
for (const input of wrapper.inputs as LinkedWidgetInput[]) {
|
||||
if (!input.widgetId) continue
|
||||
for (const linkId of input._subgraphSlot?.linkIds ?? []) {
|
||||
const link = wrapper.subgraph.getLink(linkId)
|
||||
if (link?.target_id !== innerNode.id) continue
|
||||
const targetInput = innerNode.inputs[link.target_slot]
|
||||
const widgetName = targetInput?.widget?.name
|
||||
if (!widgetName) continue
|
||||
overrides.set(
|
||||
widgetName,
|
||||
useWidgetValueStore().getWidget(input.widgetId)?.value
|
||||
)
|
||||
}
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
function isCreditsBadge(
|
||||
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
|
||||
): boolean {
|
||||
const badgeInstance = typeof badge === 'function' ? badge() : badge
|
||||
return badgeInstance.icon?.image === componentIconSvg
|
||||
}
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
function getCreditsBadge(price: string): LGraphBadge {
|
||||
const isLightTheme = colorPaletteStore.completedActivePalette.light_theme
|
||||
|
||||
return new LGraphBadge({
|
||||
text: price,
|
||||
iconOptions: {
|
||||
image: componentIconSvg,
|
||||
size: 8
|
||||
},
|
||||
fgColor:
|
||||
colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
.BADGE_FG_COLOR,
|
||||
bgColor: isLightTheme
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
}
|
||||
return {
|
||||
getCreditsBadge,
|
||||
isCreditsBadge,
|
||||
updateSubgraphCredits
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
normalizeLegacyProxyWidgetEntry,
|
||||
readHostQuarantine
|
||||
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
@@ -372,10 +374,14 @@ describe('flushProxyWidgetMigration', () => {
|
||||
|
||||
const danglingLinkId = toLinkId(999_999)
|
||||
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
|
||||
primitive.outputs[0].links = [
|
||||
...(primitive.outputs[0].links ?? []),
|
||||
danglingLinkId
|
||||
]
|
||||
useLinkStore().registerLink(host.subgraph.rootGraph.id, {
|
||||
id: danglingLinkId,
|
||||
originNodeId: primitive.id,
|
||||
originSlot: 0,
|
||||
targetNodeId: toNodeId(999_999),
|
||||
targetSlot: 0,
|
||||
type: '*'
|
||||
})
|
||||
|
||||
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
|
||||
flushProxyWidgetMigration({ hostNode: host })
|
||||
|
||||
@@ -27,8 +27,10 @@ import type {
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { LinkTopology } from '@/types/linkTopology'
|
||||
import { UNASSIGNED_NODE_ID, toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId, SerializedNodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -265,21 +267,30 @@ function pickHostValue(
|
||||
return { value: raw, isHole: false }
|
||||
}
|
||||
|
||||
function primitiveOutputTopologies(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): LinkTopology[] {
|
||||
return [
|
||||
...useLinkStore().getOutputSlotLinks(
|
||||
hostNode.subgraph.rootGraph.id,
|
||||
primitiveNode.id,
|
||||
0
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function collectTargetsStrict(
|
||||
hostNode: SubgraphNode,
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] | undefined {
|
||||
const subgraph = hostNode.subgraph
|
||||
const output = primitiveNode.outputs?.[0]
|
||||
const linkIds = output?.links ?? []
|
||||
const targets: PrimitiveBypassTargetRef[] = []
|
||||
for (const linkId of linkIds) {
|
||||
const link = subgraph.links.get(linkId)
|
||||
if (!link) return undefined
|
||||
if (link.target_id === UNASSIGNED_NODE_ID) return undefined
|
||||
for (const topology of primitiveOutputTopologies(hostNode, primitiveNode)) {
|
||||
if (!subgraph.links.get(topology.id)) return undefined
|
||||
targets.push({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
targetNodeId: topology.targetNodeId,
|
||||
targetSlot: topology.targetSlot
|
||||
})
|
||||
}
|
||||
return targets
|
||||
@@ -290,18 +301,12 @@ function collectTargetsSkippingDangling(
|
||||
primitiveNode: LGraphNode
|
||||
): PrimitiveBypassTargetRef[] {
|
||||
const subgraph = hostNode.subgraph
|
||||
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
|
||||
return linkIds.flatMap((linkId) => {
|
||||
const link = subgraph.links.get(linkId)
|
||||
return link && link.target_id !== UNASSIGNED_NODE_ID
|
||||
? [
|
||||
{
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}
|
||||
]
|
||||
: []
|
||||
})
|
||||
return primitiveOutputTopologies(hostNode, primitiveNode)
|
||||
.filter((topology) => subgraph.links.get(topology.id))
|
||||
.map((topology) => ({
|
||||
targetNodeId: topology.targetNodeId,
|
||||
targetSlot: topology.targetSlot
|
||||
}))
|
||||
}
|
||||
|
||||
function cohortDuplicatesPrimitive(
|
||||
|
||||
@@ -38,22 +38,6 @@ export function inputForWidget(
|
||||
return node.getSlotFromWidget(widget)
|
||||
}
|
||||
|
||||
/**
|
||||
* The interior source of a widget when it is a promoted subgraph input.
|
||||
* Replaces ad-hoc "is this promoted?" duck-typing: a widget is promoted iff its
|
||||
* host node is a subgraph node and its backing input slot has an interior
|
||||
* source.
|
||||
*/
|
||||
export function widgetPromotedSource(
|
||||
node: LGraphNode,
|
||||
widget: IBaseWidget
|
||||
): PromotedSource | undefined {
|
||||
if (!node.isSubgraphNode()) return undefined
|
||||
const input = inputForWidget(node, widget)
|
||||
if (!input) return undefined
|
||||
return promotedInputSource(node, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Projects a promoted subgraph input into an ordinary widget descriptor. The
|
||||
* descriptor is store-backed: type/value/options read live from
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import type { WidgetId } from '@/types/widgetId'
|
||||
|
||||
function promotedInputNames(host: {
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
demoteWidget,
|
||||
getPromotableWidgets,
|
||||
hasUnpromotedWidgets,
|
||||
isLinkedPromotion,
|
||||
isPreviewPseudoWidget,
|
||||
promoteValueWidgetViaSubgraphInput,
|
||||
promoteRecommendedWidgets,
|
||||
@@ -541,53 +540,6 @@ describe('hasUnpromotedWidgets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLinkedPromotion', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
function promoteSource(host: SubgraphNode, widgetName: string): LGraphNode {
|
||||
const node = new LGraphNode('Source')
|
||||
const input = node.addInput(widgetName, 'STRING')
|
||||
const widget = node.addWidget('text', widgetName, '', () => {})
|
||||
input.widget = { name: widget.name }
|
||||
host.subgraph.add(node)
|
||||
promoteValueWidgetViaSubgraphInput(host, node, widget)
|
||||
return node
|
||||
}
|
||||
|
||||
it('returns true for a linked promotion', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'text')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when no promotion exists', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
|
||||
expect(isLinkedPromotion(host, '999', 'nonexistent')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when sourceWidgetName does not match', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const node = promoteSource(host, 'text')
|
||||
|
||||
expect(isLinkedPromotion(host, String(node.id), 'wrong_name')).toBe(false)
|
||||
})
|
||||
|
||||
it('identifies linked widgets across different inputs', () => {
|
||||
const host = createTestSubgraphNode(createTestSubgraph())
|
||||
const nodeA = promoteSource(host, 'string_a')
|
||||
const nodeB = promoteSource(host, 'value')
|
||||
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'string_a')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), 'value')).toBe(true)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), 'value')).toBe(false)
|
||||
expect(isLinkedPromotion(host, '5', 'string_a')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reorderSubgraphInputsByName', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
@@ -723,6 +675,13 @@ describe('reorderSubgraphInputsByName', () => {
|
||||
|
||||
expect(firstLink?.target_slot).toBe(1)
|
||||
expect(secondLink?.target_slot).toBe(0)
|
||||
|
||||
const store = useLinkStore()
|
||||
const rootId = subgraph.rootGraph.id
|
||||
expect(host.isInputConnected(0)).toBe(true)
|
||||
expect(host.isInputConnected(1)).toBe(true)
|
||||
expect(store.getInputSlotLink(rootId, host.id, 0)?.id).toBe(secondLink?.id)
|
||||
expect(store.getInputSlotLink(rootId, host.id, 1)?.id).toBe(firstLink?.id)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -801,7 +760,10 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
|
||||
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
|
||||
const hostInput = host.inputs[0]
|
||||
hostInput.link = toLinkId(9999)
|
||||
const source = new LGraphNode('External Source')
|
||||
source.addOutput('out', 'STRING')
|
||||
host.graph!.add(source)
|
||||
const externalLink = source.connect(0, host, 0)!
|
||||
const promotedInputId = hostInput.widgetId
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
@@ -810,11 +772,9 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
demoteWidget(interiorNode, interiorWidget, [host])
|
||||
|
||||
expect(host.subgraph.inputs).toHaveLength(1)
|
||||
expect(host.inputs[0]?.link).toBe(9999)
|
||||
expect(host.inputs[0]?.link).toBe(externalLink.id)
|
||||
expect(host.inputs[0]?._widget).toBeUndefined()
|
||||
expect(
|
||||
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
|
||||
).toBe(false)
|
||||
expect(interiorNode.inputs[0]?.link).toBeNull()
|
||||
expect(host.widgets).toHaveLength(0)
|
||||
if (!promotedInputId) throw new Error('Missing promoted input widgetId')
|
||||
expect(useWidgetValueStore().getWidget(promotedInputId)).toBeUndefined()
|
||||
@@ -832,22 +792,21 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
})
|
||||
|
||||
it('demotes the second of two promoted widgets sharing a source widget name', () => {
|
||||
const { host, nodeA, widgetA, nodeB, widgetB } =
|
||||
buildDuplicateNamePromotion()
|
||||
const { host, nodeA, nodeB, widgetB } = buildDuplicateNamePromotion()
|
||||
|
||||
demoteWidget(nodeB, widgetB, [host])
|
||||
|
||||
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
|
||||
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
|
||||
expect(nodeB.inputs[0]?.link).toBeNull()
|
||||
expect(nodeA.inputs[0]?.link).not.toBeNull()
|
||||
})
|
||||
|
||||
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
@@ -866,12 +825,8 @@ describe('demoteWidget — axiomatic projection retraction', () => {
|
||||
demoteWidget(innerHost, promotedWidgetRef(innerHost, 'text_1'), [outerHost])
|
||||
|
||||
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
|
||||
false
|
||||
)
|
||||
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
|
||||
true
|
||||
)
|
||||
expect(innerHost.inputs.find((i) => i.name === 'text_1')?.link).toBeNull()
|
||||
expect(innerHost.inputs.find((i) => i.name === 'text')?.link).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -923,8 +878,8 @@ describe('disambiguated nested promotion identity', () => {
|
||||
const { host: innerHost } = buildDuplicateNamePromotion()
|
||||
|
||||
const outerSubgraph = createTestSubgraph()
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
outerSubgraph.add(innerHost)
|
||||
const outerHost = createTestSubgraphNode(outerSubgraph)
|
||||
|
||||
for (const input of innerHost.inputs) {
|
||||
expect(
|
||||
|
||||
@@ -30,11 +30,7 @@ type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
export type WidgetItem = [LGraphNode, IBaseWidget]
|
||||
export { CANVAS_IMAGE_PREVIEW_WIDGET }
|
||||
|
||||
export function getWidgetName(w: IBaseWidget): string {
|
||||
return w.name
|
||||
}
|
||||
|
||||
export function isLinkedPromotion(
|
||||
function isLinkedPromotion(
|
||||
subgraphNode: SubgraphNode,
|
||||
sourceNodeId: SerializedNodeId,
|
||||
sourceWidgetName: string
|
||||
@@ -70,11 +66,10 @@ function resolvePromotionSource(
|
||||
const link = subgraphNode.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(subgraphNode.subgraph)
|
||||
if (!inputNode || !Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
const { inputNode, input: targetInput } = link.resolve(
|
||||
subgraphNode.subgraph
|
||||
)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
if (inputNode.isSubgraphNode()) {
|
||||
return {
|
||||
@@ -145,6 +140,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]
|
||||
@@ -217,7 +219,7 @@ function toPromotionSource(
|
||||
): PromotedWidgetSource {
|
||||
return {
|
||||
sourceNodeId: node.id,
|
||||
sourceWidgetName: getWidgetName(widget)
|
||||
sourceWidgetName: widget.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,7 +240,7 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
sourceNode: LGraphNode,
|
||||
sourceWidget: IBaseWidget
|
||||
): CanonicalPromotionResult {
|
||||
const sourceWidgetName = getWidgetName(sourceWidget)
|
||||
const sourceWidgetName = sourceWidget.name
|
||||
if (isLinkedPromotion(subgraphNode, sourceNode.id, sourceWidgetName)) {
|
||||
return { ok: true }
|
||||
}
|
||||
@@ -262,7 +264,14 @@ export function promoteValueWidgetViaSubgraphInput(
|
||||
const hostInput = subgraphNode.inputs.find(
|
||||
(input) => input._subgraphSlot === subgraphInput
|
||||
)
|
||||
if (hostInput) hostInput.label = sourceSlot.label
|
||||
if (hostInput) {
|
||||
hostInput.label = sourceSlot.label
|
||||
const promotedState = hostInput.widgetId
|
||||
? useWidgetValueStore().getWidget(hostInput.widgetId)
|
||||
: undefined
|
||||
if (promotedState && sourceSlot.label)
|
||||
promotedState.label = sourceSlot.label
|
||||
}
|
||||
|
||||
seedNestedPromotedInputState(subgraphNode, subgraphInput.name, sourceSlot)
|
||||
|
||||
@@ -281,22 +290,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(
|
||||
@@ -366,7 +379,7 @@ export function promoteWidget(
|
||||
* Removes the host input projecting a linked promotion identified by source.
|
||||
* Returns true when an input was found and demoted.
|
||||
*/
|
||||
export function demotePromotedInput(
|
||||
function demotePromotedInput(
|
||||
subgraphNode: SubgraphNode,
|
||||
source: PromotedWidgetSource
|
||||
): boolean {
|
||||
@@ -380,7 +393,7 @@ export function demotePromotedInput(
|
||||
const linkedInput = hostInput?._subgraphSlot
|
||||
if (!linkedInput) return false
|
||||
|
||||
if (hostInput.link != null) {
|
||||
if (subgraphNode.isInputConnected(subgraphNode.inputs.indexOf(hostInput))) {
|
||||
linkedInput.disconnect()
|
||||
} else {
|
||||
subgraphNode.subgraph.removeInput(linkedInput)
|
||||
@@ -584,7 +597,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
|
||||
Sentry.addBreadcrumb({
|
||||
category: 'subgraph',
|
||||
level: 'warning',
|
||||
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
|
||||
message: `Failed to promote widget "${w.name}" on node ${n.id}: ${result.reason}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('resolveConcretePromotedWidget', () => {
|
||||
subgraph: {
|
||||
inputNode: { slots: [{ name: 'x', linkIds: [1] }] },
|
||||
getLink: () => ({
|
||||
resolve: () => ({ inputNode: recursiveNode })
|
||||
resolve: () => ({ inputNode: recursiveNode, input: recursiveInput })
|
||||
}),
|
||||
getNodeById: () => recursiveNode
|
||||
}
|
||||
|
||||
@@ -25,12 +25,8 @@ export function resolveSubgraphInputLink<TResult>(
|
||||
const link = node.subgraph.getLink(linkId)
|
||||
if (!link) continue
|
||||
|
||||
const { inputNode } = link.resolve(node.subgraph)
|
||||
if (!inputNode) continue
|
||||
if (!Array.isArray(inputNode.inputs)) continue
|
||||
|
||||
const targetInput = inputNode.inputs.find((entry) => entry.link === linkId)
|
||||
if (!targetInput) continue
|
||||
const { inputNode, input: targetInput } = link.resolve(node.subgraph)
|
||||
if (!inputNode || !targetInput) continue
|
||||
|
||||
let cachedTargetWidget:
|
||||
| ReturnType<LGraphNode['getWidgetFromSlot']>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import type { HasInitialMinSize } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
type DynamicInputs = ('INT' | 'STRING' | 'IMAGE' | DynamicInputs)[][]
|
||||
type TestAutogrowNode = LGraphNode & {
|
||||
comfyDynamic: { autogrow: Record<string, unknown> }
|
||||
@@ -60,7 +61,9 @@ function connectInput(node: LGraphNode, inputIndex: number, graph: LGraph) {
|
||||
const node2 = testNode()
|
||||
node2.addOutput('out', '*')
|
||||
graph.add(node2)
|
||||
node2.connect(0, node, inputIndex)
|
||||
const link = node2.connect(0, node, inputIndex)
|
||||
if (!link) throw new Error(`failed to connect input ${inputIndex}`)
|
||||
return link
|
||||
}
|
||||
function testNode() {
|
||||
const node: LGraphNode & Partial<HasInitialMinSize> = new LGraphNode('test')
|
||||
@@ -109,6 +112,66 @@ describe('Dynamic Combos', () => {
|
||||
expect(node.inputs[1].name).toBe('0.0.0.0')
|
||||
expect(node.inputs[3].name).toBe('2.2.0.0')
|
||||
})
|
||||
test('Shrinking rebuild keeps an unrelated input connected at its new slot', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [[], ['IMAGE', 'IMAGE'], ['IMAGE']])
|
||||
graph.add(node)
|
||||
addNodeInput(node, { name: 'other', isOptional: false, type: 'IMAGE' })
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.inputs.map((i) => i.name)).toStrictEqual([
|
||||
'0',
|
||||
'0.0.0.0',
|
||||
'0.0.0.1',
|
||||
'other'
|
||||
])
|
||||
const groupLink = connectInput(node, 1, graph)
|
||||
const removedLink = connectInput(node, 2, graph)
|
||||
const otherLink = connectInput(node, 3, graph)
|
||||
|
||||
node.widgets[0].value = '2'
|
||||
expect(node.inputs.map((i) => i.name)).toStrictEqual([
|
||||
'0',
|
||||
'0.0.0.0',
|
||||
'other'
|
||||
])
|
||||
|
||||
expect(node.isInputConnected(2)).toBe(true)
|
||||
expect(node.getInputLink(2)?.id).toBe(otherLink.id)
|
||||
expect(otherLink.target_slot).toBe(2)
|
||||
expect(node.getInputLink(1)?.id).toBe(groupLink.id)
|
||||
expect(groupLink.target_slot).toBe(1)
|
||||
expect(graph.getLink(removedLink.id)).toBeUndefined()
|
||||
})
|
||||
test('Growing rebuild keeps an unrelated input connected at its new slot', () => {
|
||||
const graph = new LGraph()
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [[], ['IMAGE'], ['IMAGE', 'IMAGE']])
|
||||
graph.add(node)
|
||||
addNodeInput(node, { name: 'other', isOptional: false, type: 'IMAGE' })
|
||||
node.widgets[0].value = '1'
|
||||
expect(node.inputs.map((i) => i.name)).toStrictEqual([
|
||||
'0',
|
||||
'0.0.0.0',
|
||||
'other'
|
||||
])
|
||||
const groupLink = connectInput(node, 1, graph)
|
||||
const otherLink = connectInput(node, 2, graph)
|
||||
|
||||
node.widgets[0].value = '2'
|
||||
expect(node.inputs.map((i) => i.name)).toStrictEqual([
|
||||
'0',
|
||||
'0.0.0.0',
|
||||
'0.0.0.1',
|
||||
'other'
|
||||
])
|
||||
|
||||
expect(node.isInputConnected(3)).toBe(true)
|
||||
expect(node.getInputLink(3)?.id).toBe(otherLink.id)
|
||||
expect(otherLink.target_slot).toBe(3)
|
||||
expect(node.getInputLink(1)?.id).toBe(groupLink.id)
|
||||
expect(groupLink.target_slot).toBe(1)
|
||||
})
|
||||
test('Dynamically added widgets have tooltips', () => {
|
||||
const node = testNode()
|
||||
addDynamicCombo(node, [['INT'], ['STRING']])
|
||||
@@ -285,5 +348,12 @@ describe('Autogrow', () => {
|
||||
'2.b2',
|
||||
'aa'
|
||||
])
|
||||
for (const slot of [0, 1, 3, 4]) {
|
||||
expect.soft(newNode.isInputConnected(slot)).toBe(true)
|
||||
expect.soft(newNode.getInputLink(slot)?.target_slot).toBe(slot)
|
||||
}
|
||||
for (const slot of [2, 5, 6]) {
|
||||
expect.soft(newNode.isInputConnected(slot)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,16 +2,15 @@ import { remove } from 'es-toolkit'
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type {
|
||||
ISlotType,
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import type { ISlotType, INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
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'
|
||||
@@ -23,6 +22,8 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { inputLink } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetId } from '@/types/widgetId'
|
||||
|
||||
@@ -48,6 +49,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
|
||||
@@ -99,13 +110,17 @@ function dynamicComboWidget(
|
||||
if (!node.widgets) throw new Error('Not Reachable')
|
||||
const newSpec = value ? options[value] : undefined
|
||||
|
||||
const inputLinks = captureInputLinks(node)
|
||||
const removedInputs = remove(node.inputs, isInGroup)
|
||||
for (const widget of remove(node.widgets, isInGroup)) {
|
||||
widget.onRemove?.()
|
||||
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 +155,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 &&
|
||||
@@ -149,39 +165,46 @@ function dynamicComboWidget(
|
||||
throw new Error('Failed to find input socket for ' + widget.name)
|
||||
return
|
||||
}
|
||||
const addedInputs = spliceInputs(node, startingInputLength).map(
|
||||
(addedInput) => {
|
||||
const addedInputs = node.inputs
|
||||
.splice(startingInputLength)
|
||||
.map((addedInput) => {
|
||||
const existingInput = node.inputs.findIndex(
|
||||
(existingInput) => addedInput.name === existingInput.name
|
||||
)
|
||||
return existingInput === -1
|
||||
? addedInput
|
||||
: spliceInputs(node, existingInput, 1)[0]
|
||||
}
|
||||
)
|
||||
: node.inputs.splice(existingInput, 1)[0]
|
||||
})
|
||||
//assume existing inputs are in correct order
|
||||
spliceInputs(node, inputInsertionPoint, 0, ...addedInputs)
|
||||
node.inputs.splice(inputInsertionPoint, 0, ...addedInputs)
|
||||
|
||||
const doomedInputs: INodeInputSlot[] = []
|
||||
const transplants: [number, LLink][] = []
|
||||
for (const input of removedInputs) {
|
||||
const inputIndex = node.inputs.findIndex((inp) => inp.name === input.name)
|
||||
const link = inputLinks.get(input)
|
||||
if (inputIndex === -1) {
|
||||
//ride through reconciliation at the tail, then disconnect cleanly
|
||||
node.inputs.push(input)
|
||||
node.removeInput(node.inputs.length - 1)
|
||||
} else {
|
||||
node.inputs[inputIndex].link = input.link
|
||||
if (!input.link) continue
|
||||
const link = node.graph?.links?.[input.link]
|
||||
if (!link) continue
|
||||
link.target_slot = inputIndex
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
node.inputs[inputIndex]
|
||||
)
|
||||
doomedInputs.push(input)
|
||||
} else if (link) {
|
||||
inputLinks.set(node.inputs[inputIndex], link)
|
||||
transplants.push([inputIndex, link])
|
||||
}
|
||||
}
|
||||
reconcileInputLinks(node, inputLinks)
|
||||
for (const [inputIndex, link] of transplants) {
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
inputIndex,
|
||||
true,
|
||||
link,
|
||||
node.inputs[inputIndex]
|
||||
)
|
||||
}
|
||||
for (const input of doomedInputs.toReversed()) {
|
||||
node.removeInput(node.inputs.indexOf(input))
|
||||
}
|
||||
|
||||
node.size[1] = node.computeSize([...node.size])[1]
|
||||
if (!node.graph) return
|
||||
@@ -233,33 +256,51 @@ export function applyDynamicInputs(
|
||||
return true
|
||||
}
|
||||
|
||||
function spliceInputs(
|
||||
node: LGraphNode,
|
||||
startIndex: number,
|
||||
deleteCount = -1,
|
||||
...toAdd: INodeInputSlot[]
|
||||
): INodeInputSlot[] {
|
||||
if (deleteCount < 0) return node.inputs.splice(startIndex)
|
||||
const ret = node.inputs.splice(startIndex, deleteCount, ...toAdd)
|
||||
node.inputs.slice(startIndex).forEach((input, index) => {
|
||||
const link = input.link && node.graph?.links?.get(input.link)
|
||||
if (link) link.target_slot = startIndex + index
|
||||
})
|
||||
return ret
|
||||
type InputLinks = Map<INodeInputSlot, LLink>
|
||||
|
||||
/**
|
||||
* Snapshot of each input slot's link, keyed by slot object. Must be taken
|
||||
* while the inputs array is at rest (array position == registered
|
||||
* target_slot); the slot objects then carry the association through a
|
||||
* rebuild's array shuffles, as the deleted `input.link` mirror used to.
|
||||
*/
|
||||
function captureInputLinks(node: LGraphNode): InputLinks {
|
||||
const links: InputLinks = new Map()
|
||||
const { graph } = node
|
||||
if (!graph) return links
|
||||
for (const [index, input] of node.inputs.entries()) {
|
||||
const link = inputLink(graph, node.id, index)
|
||||
if (link) links.set(input, link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
/** Re-keys every captured link to its slot's final array position. */
|
||||
function reconcileInputLinks(node: LGraphNode, links: InputLinks) {
|
||||
for (const [index, input] of node.inputs.entries()) {
|
||||
const link = links.get(input)
|
||||
if (link && link.target_slot !== index) link.target_slot = index
|
||||
}
|
||||
}
|
||||
|
||||
function changeOutputType(
|
||||
node: LGraphNode,
|
||||
output: INodeOutputSlot,
|
||||
slot: number,
|
||||
combinedType: ISlotType
|
||||
) {
|
||||
const output = node.outputs[slot]
|
||||
if (output.type === combinedType) return
|
||||
output.type = combinedType
|
||||
|
||||
//check and potentially remove links
|
||||
if (!node.graph) return
|
||||
for (const link_id of output.links ?? []) {
|
||||
const link = node.graph.links[link_id]
|
||||
const topologies = useLinkStore().getOutputSlotLinks(
|
||||
node.graph.rootGraph.id,
|
||||
node.id,
|
||||
slot
|
||||
)
|
||||
for (const topology of topologies) {
|
||||
const link = node.graph.links[topology.id]
|
||||
if (!link) continue
|
||||
const { input, inputNode, subgraphOutput } = link.resolve(node.graph)
|
||||
const inputType = (input ?? subgraphOutput)?.type
|
||||
@@ -310,8 +351,7 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
(inp) => inp.name in matchGroup
|
||||
)
|
||||
const connectedTypes = groupInputs.map((inp) => {
|
||||
if (!inp.link) return '*'
|
||||
const link = this.graph!.links[inp.link]
|
||||
const link = this.getInputLink(this.inputs.indexOf(inp))
|
||||
if (!link) return '*'
|
||||
const { output, subgraphInput } = link.resolve(this.graph!)
|
||||
return (output ?? subgraphInput)?.type ?? '*'
|
||||
@@ -335,10 +375,10 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
|
||||
})
|
||||
const outputType = commonType(...connectedTypes)
|
||||
if (!outputType) throw new Error('invalid connection')
|
||||
this.outputs.forEach((output, idx) => {
|
||||
this.outputs.forEach((_output, idx) => {
|
||||
if (!(outputGroups?.[idx] == matchKey)) return
|
||||
this.outputs[idx] = shallowReactive(this.outputs[idx])
|
||||
changeOutputType(this, output, outputType)
|
||||
changeOutputType(this, idx, outputType)
|
||||
})
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
@@ -365,11 +405,12 @@ function applyMatchType(node: LGraphNode, inputSpec: InputSpecV2) {
|
||||
const input = node.inputs[index]
|
||||
if (!input) return
|
||||
node.inputs[index] = shallowReactive(input)
|
||||
const existingLink = node.getInputLink(index)
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
index,
|
||||
!!input.link,
|
||||
input.link ? node.graph?.links?.[input.link] : undefined,
|
||||
!!existingLink,
|
||||
existingLink ?? undefined,
|
||||
input
|
||||
)
|
||||
})
|
||||
@@ -401,6 +442,7 @@ function addAutogrowGroup(
|
||||
const { max, min, inputSpecs } = node.comfyDynamic.autogrow[groupName]
|
||||
if (ordinal >= max) return
|
||||
|
||||
const inputLinks = captureInputLinks(node)
|
||||
const namedSpecs = inputSpecs.map((input) => ({
|
||||
...input,
|
||||
isOptional: ordinal >= (min ?? 0) || input.isOptional,
|
||||
@@ -409,7 +451,7 @@ function addAutogrowGroup(
|
||||
|
||||
const newInputs = namedSpecs.map((namedSpec) => {
|
||||
addNodeInput(node, namedSpec)
|
||||
const input = spliceInputs(node, node.inputs.length - 1, 1)[0]
|
||||
const input = node.inputs.splice(node.inputs.length - 1, 1)[0]
|
||||
if (inputSpecs.length !== 1 || (INLINE_INPUTS && !input.widget))
|
||||
ensureWidgetForInput(node, input)
|
||||
return input
|
||||
@@ -420,8 +462,8 @@ function addAutogrowGroup(
|
||||
node.inputs,
|
||||
(inp) => inp.name === newInput.name
|
||||
)) {
|
||||
//NOTE: link.target_slot is updated on spliceInputs call
|
||||
newInput.link ??= existingInput.link
|
||||
const link = inputLinks.get(existingInput)
|
||||
if (link && !inputLinks.has(newInput)) inputLinks.set(newInput, link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +477,8 @@ function addAutogrowGroup(
|
||||
inp.name.startsWith(targetName)
|
||||
)
|
||||
const insertionIndex = lastIndex === -1 ? node.inputs.length : lastIndex + 1
|
||||
spliceInputs(node, insertionIndex, 0, ...newInputs)
|
||||
node.inputs.splice(insertionIndex, 0, ...newInputs)
|
||||
reconcileInputLinks(node, inputLinks)
|
||||
app.canvas?.setDirty(true, true)
|
||||
}
|
||||
|
||||
@@ -497,6 +540,13 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
return
|
||||
}
|
||||
app.canvas?.setDirty(true, true)
|
||||
// Snapshot each group slot's link before shuffling: donors are always read
|
||||
// pre-shuffle, matching the sequential copy the mirror used to perform.
|
||||
const linkByOrdinal = groupInputs.map((inp) =>
|
||||
node.graph
|
||||
? inputLink(node.graph, node.id, node.inputs.indexOf(inp))
|
||||
: undefined
|
||||
)
|
||||
//groupBy would be nice here, but may not be supported
|
||||
for (let column = 0; column < stride; column++) {
|
||||
for (
|
||||
@@ -505,9 +555,7 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
bubbleOrdinal += stride
|
||||
) {
|
||||
const curInput = groupInputs[bubbleOrdinal]
|
||||
curInput.link = groupInputs[bubbleOrdinal + stride].link
|
||||
if (!curInput.link) continue
|
||||
const link = node.graph?.links[curInput.link]
|
||||
const link = linkByOrdinal[bubbleOrdinal + stride]
|
||||
if (!link) continue
|
||||
const curIndex = node.inputs.findIndex((inp) => inp === curInput)
|
||||
if (curIndex === -1) throw new Error('missing input')
|
||||
@@ -522,7 +570,6 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
}
|
||||
const lastInput = groupInputs.at(column - stride)
|
||||
if (!lastInput) continue
|
||||
lastInput.link = null
|
||||
node.onConnectionsChange?.(
|
||||
LiteGraph.INPUT,
|
||||
node.inputs.length + column - stride,
|
||||
@@ -534,15 +581,23 @@ function autogrowInputDisconnected(index: number, node: AutogrowNode) {
|
||||
const removalChecks = groupInputs.slice(min * stride)
|
||||
let i
|
||||
for (i = removalChecks.length - stride; i >= 0; i -= stride) {
|
||||
if (removalChecks.slice(i, i + stride).some((inp) => inp.link)) break
|
||||
if (
|
||||
removalChecks
|
||||
.slice(i, i + stride)
|
||||
.some((inp) => node.isInputConnected(node.inputs.indexOf(inp)))
|
||||
)
|
||||
break
|
||||
}
|
||||
const toRemove = removalChecks.slice(i + stride * 2)
|
||||
remove(node.inputs, (inp) => toRemove.includes(inp))
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
|
||||
setActivePinia(createTestingPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
const { addNodeInput } = useLitegraphService()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import type { SerialisedLLinkArray } from '@/lib/litegraph/src/LLink'
|
||||
import type { LGraphNodeConstructor } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { outputLinks } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { parseNodeId } from '@/types/nodeId'
|
||||
import type {
|
||||
ComfyNode,
|
||||
@@ -920,9 +921,7 @@ export class GroupNodeHandler {
|
||||
for (const innerInputId in map) {
|
||||
const groupSlotId = map[Number(innerInputId)]
|
||||
if (groupSlotId == null) continue
|
||||
const slot = node.inputs[groupSlotId]
|
||||
if (slot.link == null) continue
|
||||
const link = app.rootGraph.links[slot.link]
|
||||
const link = node.getInputLink(groupSlotId)
|
||||
if (!link) continue
|
||||
const originNode = app.rootGraph.getNodeById(link.origin_id)
|
||||
originNode?.connect(link.origin_slot, newNode, +innerInputId)
|
||||
@@ -936,14 +935,10 @@ export class GroupNodeHandler {
|
||||
groupOutputId < node.outputs?.length;
|
||||
groupOutputId++
|
||||
) {
|
||||
const output = node.outputs[groupOutputId]
|
||||
if (!output.links) continue
|
||||
const links = [...output.links]
|
||||
for (const l of links) {
|
||||
const links = outputLinks(app.rootGraph, node.id, groupOutputId)
|
||||
for (const link of links) {
|
||||
const slot = newToOldOutputMap[groupOutputId]
|
||||
if (!slot) continue
|
||||
const link = app.rootGraph.links[l]
|
||||
if (!link) continue
|
||||
const targetNode = app.rootGraph.getNodeById(link.target_id)
|
||||
const selectedId = parseNodeId(selectedIds[slot.node.index ?? 0])
|
||||
const newNode = selectedId
|
||||
|
||||
@@ -635,9 +635,10 @@ useExtensionService().registerExtension({
|
||||
const extrinsics = result?.[3]
|
||||
const intrinsics = result?.[4]
|
||||
|
||||
modelWidget.value = filePath?.replaceAll('\\', '/')
|
||||
const modelFilePath = filePath?.replaceAll('\\', '/')
|
||||
modelWidget.value = modelFilePath
|
||||
|
||||
node.properties['Last Time Model File'] = modelWidget.value
|
||||
node.properties['Last Time Model File'] = modelFilePath
|
||||
|
||||
const settings = {
|
||||
loadFolder: 'output',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISlotType } from '@/lib/litegraph/src/interfaces'
|
||||
import { outputLinks } from '@/lib/litegraph/src/node/slotLinks'
|
||||
|
||||
import { app } from '../../scripts/app'
|
||||
import { getWidgetConfig, mergeIfValid, setWidgetConfig } from './widgetInputs'
|
||||
@@ -62,19 +63,13 @@ app.registerExtension({
|
||||
|
||||
// Prevent multiple connections to different types when we have no input
|
||||
if (connected && type === LiteGraph.OUTPUT) {
|
||||
const links = outputLinks(graph, this.id, 0)
|
||||
// Ignore wildcard nodes as these will be updated to real types
|
||||
const types = new Set(
|
||||
this.outputs[0].links
|
||||
?.map((l) => graph.links[l]?.type)
|
||||
?.filter((t) => t && t !== '*') ?? []
|
||||
links.map((l) => l.type).filter((t) => t && t !== '*')
|
||||
)
|
||||
if (types.size > 1) {
|
||||
const linksToDisconnect = []
|
||||
for (const linkId of this.outputs[0].links ?? []) {
|
||||
const link = graph.links[linkId]
|
||||
linksToDisconnect.push(link)
|
||||
}
|
||||
linksToDisconnect.pop()
|
||||
const linksToDisconnect = links.slice(0, -1)
|
||||
for (const link of linksToDisconnect) {
|
||||
const node = graph.getNodeById(link.target_id)
|
||||
node?.disconnectInput(link.target_slot)
|
||||
@@ -89,9 +84,8 @@ app.registerExtension({
|
||||
let inputNode = null
|
||||
while (currentNode) {
|
||||
updateNodes.unshift(currentNode)
|
||||
const linkId = currentNode.inputs[0].link
|
||||
if (linkId !== null) {
|
||||
const link = graph.links[linkId]
|
||||
if (currentNode.isInputConnected(0)) {
|
||||
const link = currentNode.getInputLink(0)
|
||||
if (!link) return
|
||||
const node = graph.getNodeById(link.origin_id)
|
||||
if (!node) return
|
||||
@@ -122,13 +116,7 @@ app.registerExtension({
|
||||
let outputType = null
|
||||
while (nodes.length) {
|
||||
currentNode = nodes.pop()!
|
||||
const outputs = currentNode.outputs?.[0]?.links ?? []
|
||||
for (const linkId of outputs) {
|
||||
const link = graph.links[linkId]
|
||||
|
||||
// When disconnecting sometimes the link is still registered
|
||||
if (!link) continue
|
||||
|
||||
for (const link of outputLinks(graph, currentNode.id, 0)) {
|
||||
const node = graph.getNodeById(link.target_id)
|
||||
if (!node) continue
|
||||
if (node instanceof RerouteNode) {
|
||||
@@ -176,9 +164,7 @@ app.registerExtension({
|
||||
: ''
|
||||
node.setSize(node.computeSize())
|
||||
|
||||
for (const l of node.outputs[0].links || []) {
|
||||
const link = graph.links[l]
|
||||
if (!link) continue
|
||||
for (const link of outputLinks(graph, node.id, 0)) {
|
||||
link.color = color
|
||||
|
||||
if (app.configuringGraph) continue
|
||||
@@ -215,11 +201,9 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
if (inputNode?.inputs?.[0]?.link) {
|
||||
const link = graph.links[inputNode.inputs[0].link]
|
||||
if (link) {
|
||||
link.color = color
|
||||
}
|
||||
const inputNodeLink = inputNode?.getInputLink(0)
|
||||
if (inputNodeLink) {
|
||||
inputNodeLink.color = color
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -7,14 +7,13 @@ import type {
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
TWidgetValue
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import { outputHasLinks, outputLinks } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import { createAssetWidget } from '@/platform/assets/utils/createAssetWidget'
|
||||
import type { ComfyNodeDef, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import {
|
||||
ComfyWidgets,
|
||||
addValueControlWidgets,
|
||||
@@ -29,7 +28,7 @@ import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
const replacePropertyName = 'Run widget replace on values'
|
||||
export class PrimitiveNode extends LGraphNode {
|
||||
controlValues?: TWidgetValue[]
|
||||
controlValues?: WidgetValue[]
|
||||
lastType?: string
|
||||
static override category: string
|
||||
constructor(title: string) {
|
||||
@@ -71,7 +70,11 @@ export class PrimitiveNode extends LGraphNode {
|
||||
}
|
||||
|
||||
override onAfterGraphConfigured() {
|
||||
if (this.outputs[0].links?.length && !this.widgets?.length) {
|
||||
if (
|
||||
this.graph &&
|
||||
outputHasLinks(this.graph, this.id, 0) &&
|
||||
!this.widgets?.length
|
||||
) {
|
||||
this._onFirstConnection()
|
||||
|
||||
// Populate widget values from config data
|
||||
@@ -99,16 +102,16 @@ export class PrimitiveNode extends LGraphNode {
|
||||
return
|
||||
}
|
||||
|
||||
const links = this.outputs[0].links
|
||||
const hasLinks = this.graph ? outputHasLinks(this.graph, this.id, 0) : false
|
||||
if (connected) {
|
||||
if (links?.length && !this.widgets?.length) {
|
||||
if (hasLinks && !this.widgets?.length) {
|
||||
this._onFirstConnection()
|
||||
}
|
||||
} else {
|
||||
// We may have removed a link that caused the constraints to change
|
||||
this._mergeWidgetConfig()
|
||||
|
||||
if (!links?.length) {
|
||||
if (!hasLinks) {
|
||||
this.onLastDisconnect()
|
||||
}
|
||||
}
|
||||
@@ -127,7 +130,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
return false
|
||||
}
|
||||
|
||||
if (this.outputs[slot].links?.length) {
|
||||
if (this.graph && outputHasLinks(this.graph, this.id, slot)) {
|
||||
const valid = this._isValidConnection(input)
|
||||
if (valid) {
|
||||
// On connect of additional outputs, copy our value to their widget
|
||||
@@ -141,13 +144,15 @@ export class PrimitiveNode extends LGraphNode {
|
||||
|
||||
private _onFirstConnection(recreating?: boolean) {
|
||||
// First connection can fire before the graph is ready on initial load so random things can be missing
|
||||
if (!this.outputs[0].links || !this.graph) {
|
||||
if (!this.graph) {
|
||||
this.onLastDisconnect()
|
||||
return
|
||||
}
|
||||
const linkId = this.outputs[0].links[0]
|
||||
const link = this.graph.links[linkId]
|
||||
if (!link) return
|
||||
const [link] = outputLinks(this.graph, this.id, 0)
|
||||
if (!link) {
|
||||
if (!outputHasLinks(this.graph, this.id, 0)) this.onLastDisconnect()
|
||||
return
|
||||
}
|
||||
|
||||
const theirNode = this.graph.getNodeById(link.target_id)
|
||||
if (!theirNode || !theirNode.inputs) return
|
||||
@@ -320,14 +325,14 @@ export class PrimitiveNode extends LGraphNode {
|
||||
private _mergeWidgetConfig() {
|
||||
// Merge widget configs if the node has multiple outputs
|
||||
const output = this.outputs[0]
|
||||
const links = output.links ?? []
|
||||
const links = this.graph ? outputLinks(this.graph, this.id, 0) : []
|
||||
|
||||
const hasConfig = !!output.widget?.[CONFIG]
|
||||
if (hasConfig) {
|
||||
delete output.widget?.[CONFIG]
|
||||
}
|
||||
|
||||
if (links?.length < 2 && hasConfig) {
|
||||
if (links.length < 2 && hasConfig) {
|
||||
// Copy the widget options from the source
|
||||
if (links.length) {
|
||||
this.recreateWidget()
|
||||
@@ -340,10 +345,7 @@ export class PrimitiveNode extends LGraphNode {
|
||||
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
|
||||
if (!isNumber || !this.graph) return
|
||||
|
||||
for (const linkId of links) {
|
||||
const link = this.graph.links[linkId]
|
||||
if (!link) continue // Can be null when removing a node
|
||||
|
||||
for (const link of links) {
|
||||
const theirNode = this.graph.getNodeById(link.target_id)
|
||||
if (!theirNode) continue
|
||||
const theirInput = theirNode.inputs[link.target_slot]
|
||||
@@ -456,11 +458,11 @@ export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
|
||||
}
|
||||
|
||||
if (!(slot instanceof NodeSlot)) return
|
||||
const graph = slot.node.graph
|
||||
if (!graph) return
|
||||
const link = graph.getLink(slot.link)
|
||||
const { node } = slot
|
||||
if (!node.graph) return
|
||||
const link = node.getInputLink(node.inputs.indexOf(slot))
|
||||
if (!link) return
|
||||
const originNode = graph.getNodeById(link.origin_id)
|
||||
const originNode = node.graph.getNodeById(link.origin_id)
|
||||
if (!originNode || !isPrimitiveNode(originNode)) return
|
||||
if (config) {
|
||||
originNode.recreateWidget()
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
LLink
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import { createMockLLink } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -21,7 +21,11 @@ vi.mock('@/scripts/app', () => ({
|
||||
|
||||
import { applyFirstWidgetValueToGraph } from './widgetValuePropagation'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
type SourceNode = Pick<LGraphNode, 'id' | 'graph' | 'widgets'>
|
||||
type TargetNode = Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
|
||||
|
||||
const GRAPH_ID: UUID = 'graph-test'
|
||||
const SOURCE_NODE_ID = toNodeId(1)
|
||||
|
||||
function createWidget(
|
||||
name: string,
|
||||
@@ -35,11 +39,8 @@ function createWidget(
|
||||
})
|
||||
}
|
||||
|
||||
function createTargetNode(
|
||||
widget: IBaseWidget,
|
||||
id = 7
|
||||
): Pick<LGraphNode, 'id' | 'inputs' | 'widgets'> {
|
||||
return fromPartial<Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>>({
|
||||
function createTargetNode(widget: IBaseWidget, id = 7): TargetNode {
|
||||
return fromPartial<TargetNode>({
|
||||
id: toNodeId(id),
|
||||
inputs: [
|
||||
fromPartial<INodeInputSlot>({
|
||||
@@ -50,39 +51,40 @@ function createTargetNode(
|
||||
})
|
||||
}
|
||||
|
||||
function createLink(targetId: LLink['target_id'], targetSlot = 0): LLink {
|
||||
return createMockLLink({
|
||||
target_id: targetId,
|
||||
target_slot: targetSlot
|
||||
})
|
||||
}
|
||||
|
||||
function createSourceNode(options: {
|
||||
link: LLink
|
||||
targetNode: Pick<LGraphNode, 'id' | 'inputs' | 'widgets'>
|
||||
targetNode: TargetNode
|
||||
widgets?: IBaseWidget[]
|
||||
}): SourceNode {
|
||||
useLinkStore().registerLink(GRAPH_ID, {
|
||||
id: toLinkId(1),
|
||||
originNodeId: SOURCE_NODE_ID,
|
||||
originSlot: 0,
|
||||
targetNodeId: options.targetNode.id,
|
||||
targetSlot: 0,
|
||||
type: 'INT'
|
||||
})
|
||||
return {
|
||||
id: SOURCE_NODE_ID,
|
||||
graph: {
|
||||
links: { 1: options.link },
|
||||
getNodeById: vi.fn((id: LLink['target_id']) =>
|
||||
rootGraph: { id: GRAPH_ID },
|
||||
getNodeById: vi.fn((id: TargetNode['id']) =>
|
||||
id === options.targetNode.id ? options.targetNode : null
|
||||
)
|
||||
} as unknown as NonNullable<LGraphNode['graph']>,
|
||||
outputs: [{ links: [1] } as INodeOutputSlot],
|
||||
widgets: options.widgets ?? []
|
||||
}
|
||||
}
|
||||
|
||||
describe('applyFirstWidgetValueToGraph', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('returns early when the source widget is missing', () => {
|
||||
const targetCallback = vi.fn()
|
||||
const targetWidget = createWidget('value', 'unchanged', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode
|
||||
})
|
||||
const sourceNode = createSourceNode({ targetNode })
|
||||
|
||||
expect(() => applyFirstWidgetValueToGraph(sourceNode)).not.toThrow()
|
||||
expect(targetWidget.value).toBe('unchanged')
|
||||
@@ -94,7 +96,6 @@ describe('applyFirstWidgetValueToGraph', () => {
|
||||
const targetWidget = createWidget('value', 'old', targetCallback)
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'new value')]
|
||||
})
|
||||
@@ -116,7 +117,6 @@ describe('applyFirstWidgetValueToGraph', () => {
|
||||
const targetWidget = createWidget('value', 'old')
|
||||
const targetNode = createTargetNode(targetWidget)
|
||||
const sourceNode = createSourceNode({
|
||||
link: createLink(targetNode.id),
|
||||
targetNode,
|
||||
widgets: [createWidget('source', 'draft')]
|
||||
})
|
||||
|
||||
@@ -2,18 +2,30 @@ import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
import type { LLink } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
|
||||
type SourceNode = Pick<LGraphNode, 'graph' | 'outputs' | 'widgets'>
|
||||
type SourceNode = Pick<LGraphNode, 'id' | 'graph' | 'widgets'>
|
||||
|
||||
interface TargetEndpoint {
|
||||
targetNodeId: NodeId
|
||||
targetSlot: number
|
||||
}
|
||||
|
||||
export function applyFirstWidgetValueToGraph(
|
||||
node: SourceNode,
|
||||
extraLinks: LLink[] = [],
|
||||
transformValue?: (value: TWidgetValue) => TWidgetValue
|
||||
transformValue?: (value: WidgetValue) => WidgetValue
|
||||
) {
|
||||
const output = node.outputs[0]
|
||||
if (!output?.links?.length || !node.graph) return
|
||||
const { graph } = node
|
||||
if (!graph) return
|
||||
|
||||
const linked = [
|
||||
...useLinkStore().getOutputSlotLinks(graph.rootGraph.id, node.id, 0)
|
||||
]
|
||||
if (!linked.length) return
|
||||
|
||||
const sourceWidget = node.widgets?.[0]
|
||||
if (!sourceWidget) return
|
||||
@@ -25,18 +37,22 @@ export function applyFirstWidgetValueToGraph(
|
||||
|
||||
const graphMouse: Point = app.canvas?.graph_mouse ?? [0, 0]
|
||||
|
||||
const links = [
|
||||
...output.links.map((linkId) => node.graph!.links[linkId]),
|
||||
...extraLinks
|
||||
const endpoints: TargetEndpoint[] = [
|
||||
...linked.map(({ targetNodeId, targetSlot }) => ({
|
||||
targetNodeId,
|
||||
targetSlot
|
||||
})),
|
||||
...extraLinks.map((link) => ({
|
||||
targetNodeId: link.target_id,
|
||||
targetSlot: link.target_slot
|
||||
}))
|
||||
]
|
||||
|
||||
for (const linkInfo of links) {
|
||||
if (!linkInfo) continue
|
||||
|
||||
const targetNode = node.graph.getNodeById(linkInfo.target_id)
|
||||
const input = targetNode?.inputs[linkInfo.target_slot]
|
||||
for (const endpoint of endpoints) {
|
||||
const targetNode = graph.getNodeById(endpoint.targetNodeId)
|
||||
const input = targetNode?.inputs[endpoint.targetSlot]
|
||||
if (!targetNode || !input) {
|
||||
console.warn('Unable to resolve node or input for link', linkInfo)
|
||||
console.warn('Unable to resolve node or input for link', endpoint)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
86
src/lib/litegraph/src/LGraph.badges.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
|
||||
|
||||
import {
|
||||
createTestSubgraphData,
|
||||
createTestSubgraphNode
|
||||
} from './subgraph/__fixtures__/subgraphHelpers'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraph node badge registration', () => {
|
||||
it('registers a node in the root bucket on add, unregisters on remove', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('n')
|
||||
|
||||
graph.add(node)
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
|
||||
node.id
|
||||
])
|
||||
|
||||
graph.remove(node)
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('registers only after onNodeAdded has fired', () => {
|
||||
const graph = new LGraph()
|
||||
const node = new LGraphNode('n')
|
||||
let registeredDuringCallback: boolean | undefined
|
||||
graph.onNodeAdded = () => {
|
||||
registeredDuringCallback = useNodeBadgeStore()
|
||||
.registeredNodeIds(graph.rootGraph.id)
|
||||
.includes(node.id)
|
||||
}
|
||||
|
||||
graph.add(node)
|
||||
|
||||
expect(registeredDuringCallback).toBe(false)
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graph.rootGraph.id)).toEqual([
|
||||
node.id
|
||||
])
|
||||
})
|
||||
|
||||
it('clears the root bucket when the root graph is cleared', () => {
|
||||
const graph = new LGraph()
|
||||
graph.add(new LGraphNode('a'))
|
||||
graph.add(new LGraphNode('b'))
|
||||
const graphId = graph.rootGraph.id
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(useNodeBadgeStore().registeredNodeIds(graphId)).toEqual([])
|
||||
})
|
||||
|
||||
it('registers subgraph nodes in the root bucket', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
const inner = new LGraphNode('inner')
|
||||
|
||||
subgraph.add(inner)
|
||||
|
||||
expect(
|
||||
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
|
||||
).toContainEqual(inner.id)
|
||||
})
|
||||
|
||||
it('unregisters inner nodes when the subgraph definition is collected', () => {
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = rootGraph.createSubgraph(createTestSubgraphData())
|
||||
const inner = new LGraphNode('inner')
|
||||
subgraph.add(inner)
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.remove(subgraphNode)
|
||||
|
||||
expect(
|
||||
useNodeBadgeStore().registeredNodeIds(rootGraph.rootGraph.id)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
228
src/lib/litegraph/src/LGraph.inputSlotRealign.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
ExportedSubgraph,
|
||||
ISerialisedNode,
|
||||
SerialisableGraph
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const DEFINITION_ORDER = ['in_a', 'in_b', 'in_c']
|
||||
|
||||
/**
|
||||
* Mimics ComfyNode.configure (src/services/litegraphService.ts): reorders the
|
||||
* serialized inputs array in place on `data` to match the current node
|
||||
* definition order before delegating to LGraphNode.configure. This is the
|
||||
* issue #3348 scenario: a workflow saved before the node definition's input
|
||||
* order changed (e.g. a forceInput migration in a node pack update).
|
||||
*/
|
||||
class ReorderTargetNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'ReorderTarget')
|
||||
for (const name of DEFINITION_ORDER) this.addInput(name, 'number')
|
||||
}
|
||||
|
||||
override configure(data: ISerialisedNode): void {
|
||||
data.inputs = [...(data.inputs ?? [])].sort(
|
||||
(a, b) =>
|
||||
DEFINITION_ORDER.indexOf(a.name) - DEFINITION_ORDER.indexOf(b.name)
|
||||
)
|
||||
super.configure(data)
|
||||
}
|
||||
}
|
||||
|
||||
class SourceNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'Source')
|
||||
this.addOutput('out', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
const SUBGRAPH_ID = 'ab111111-1111-4111-8111-111111111111'
|
||||
|
||||
/**
|
||||
* Source and reorder-target nodes connected by three links whose saved
|
||||
* `target_slot` matches the saved input order [in_b, in_c, in_a], which is
|
||||
* stale relative to the current definition order [in_a, in_b, in_c].
|
||||
*/
|
||||
function shiftedNodesAndLinks(sourceId: number, targetId: number) {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: sourceId,
|
||||
type: 'test/RealignSource',
|
||||
pos: [0, 0] as [number, number],
|
||||
size: [140, 60] as [number, number],
|
||||
flags: {},
|
||||
order: 0,
|
||||
mode: 0,
|
||||
inputs: [],
|
||||
outputs: [{ name: 'out', type: 'number', links: [1, 2, 3] }],
|
||||
properties: {}
|
||||
},
|
||||
{
|
||||
id: targetId,
|
||||
type: 'test/RealignTarget',
|
||||
pos: [300, 0] as [number, number],
|
||||
size: [140, 80] as [number, number],
|
||||
flags: {},
|
||||
order: 1,
|
||||
mode: 0,
|
||||
inputs: [
|
||||
{ name: 'in_b', type: 'number', link: 1 },
|
||||
{ name: 'in_c', type: 'number', link: 2 },
|
||||
{ name: 'in_a', type: 'number', link: 3 }
|
||||
],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}
|
||||
],
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
origin_id: sourceId,
|
||||
origin_slot: 0,
|
||||
target_id: targetId,
|
||||
target_slot: 0,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
origin_id: sourceId,
|
||||
origin_slot: 0,
|
||||
target_id: targetId,
|
||||
target_slot: 1,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
origin_id: sourceId,
|
||||
origin_slot: 0,
|
||||
target_id: targetId,
|
||||
target_slot: 2,
|
||||
type: 'number'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function emptySubgraphDefinition(): ExportedSubgraph {
|
||||
return {
|
||||
id: SUBGRAPH_ID,
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
|
||||
name: 'Empty Subgraph',
|
||||
config: {},
|
||||
inputNode: { id: SUBGRAPH_INPUT_ID, bounding: [0, 0, 120, 60] },
|
||||
outputNode: { id: SUBGRAPH_OUTPUT_ID, bounding: [300, 0, 120, 60] },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
widgets: [],
|
||||
nodes: [],
|
||||
links: []
|
||||
}
|
||||
}
|
||||
|
||||
function savedWorkflow(withSubgraphDefinition: boolean): SerialisableGraph {
|
||||
return {
|
||||
id: 'ab000000-0000-4000-8000-000000000001',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: { lastNodeId: 2, lastLinkId: 3, lastGroupId: 0, lastRerouteId: 0 },
|
||||
...shiftedNodesAndLinks(1, 2),
|
||||
...(withSubgraphDefinition
|
||||
? { definitions: { subgraphs: [emptySubgraphDefinition()] } }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function savedWorkflowWithShiftInsideSubgraph(): SerialisableGraph {
|
||||
return {
|
||||
id: 'ab000000-0000-4000-8000-000000000002',
|
||||
version: 1,
|
||||
revision: 0,
|
||||
state: { lastNodeId: 0, lastLinkId: 0, lastGroupId: 0, lastRerouteId: 0 },
|
||||
nodes: [],
|
||||
links: [],
|
||||
definitions: {
|
||||
subgraphs: [
|
||||
{
|
||||
...emptySubgraphDefinition(),
|
||||
name: 'Subgraph With Shifted Inputs',
|
||||
state: {
|
||||
lastNodeId: 20,
|
||||
lastLinkId: 3,
|
||||
lastGroupId: 0,
|
||||
lastRerouteId: 0
|
||||
},
|
||||
...shiftedNodesAndLinks(10, 20)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LINK_BY_INPUT_NAME: Record<string, number> = {
|
||||
in_a: 3,
|
||||
in_b: 1,
|
||||
in_c: 2
|
||||
}
|
||||
|
||||
function assertLinksRealigned(graph: LGraph, targetNodeId: NodeId) {
|
||||
const target = graph.getNodeById(targetNodeId)!
|
||||
const linkStore = useLinkStore()
|
||||
|
||||
for (const [slot, input] of target.inputs.entries()) {
|
||||
const expectedLinkId = toLinkId(LINK_BY_INPUT_NAME[input.name])
|
||||
const link = graph.links.get(expectedLinkId)!
|
||||
|
||||
expect(link.target_slot, `link.target_slot for input ${input.name}`).toBe(
|
||||
slot
|
||||
)
|
||||
expect(
|
||||
linkStore.getInputSlotLink(graph.rootGraph.id, target.id, slot)?.id,
|
||||
`store registration for input ${input.name} at slot ${slot}`
|
||||
).toBe(expectedLinkId)
|
||||
}
|
||||
}
|
||||
|
||||
describe('LGraph.configure input slot realignment (#3348)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.registerNodeType('test/RealignSource', SourceNode)
|
||||
LiteGraph.registerNodeType('test/RealignTarget', ReorderTargetNode)
|
||||
})
|
||||
|
||||
it('re-keys links to reordered input slots', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(savedWorkflow(false))
|
||||
|
||||
assertLinksRealigned(graph, toNodeId(2))
|
||||
})
|
||||
|
||||
it('re-keys root links when subgraph definitions force a data clone', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(savedWorkflow(true))
|
||||
|
||||
assertLinksRealigned(graph, toNodeId(2))
|
||||
})
|
||||
|
||||
it('re-keys links of nodes inside subgraph definitions', () => {
|
||||
const graph = new LGraph()
|
||||
graph.configure(savedWorkflowWithShiftInsideSubgraph())
|
||||
|
||||
const subgraph = graph.subgraphs.get(SUBGRAPH_ID)!
|
||||
assertLinksRealigned(subgraph, toNodeId(20))
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,14 @@
|
||||
import { describe } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ISerialisedGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraph Serialisation', () => {
|
||||
test('can (de)serialise node / group titles', ({ expect, minimalGraph }) => {
|
||||
const nodeTitle = 'Test Node'
|
||||
|
||||
@@ -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,8 +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)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
graph._addLink(dup)
|
||||
return dup
|
||||
}
|
||||
|
||||
@@ -841,7 +946,8 @@ describe('_removeDuplicateLinks', () => {
|
||||
for (let i = 0; i < 3; i++) injectDuplicateLink(graph, source, target)
|
||||
|
||||
expect(graph._links.size).toBe(4)
|
||||
expect(source.outputs[0].links).toHaveLength(4)
|
||||
// The derived output.links view never contained the contested duplicates.
|
||||
expect(source.outputs[0].links).toHaveLength(1)
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
@@ -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!
|
||||
@@ -884,19 +1004,21 @@ describe('_removeDuplicateLinks', () => {
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(validLinkId)).toBe(true)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
expect(target.inputs[1].link).toBe(validLinkId)
|
||||
const store = useLinkStore()
|
||||
expect(store.getInputSlotLink(graph.rootGraph.id, target.id, 0)?.id).toBe(
|
||||
validLinkId
|
||||
)
|
||||
})
|
||||
|
||||
it('repairs input.link when it points to a removed duplicate', () => {
|
||||
it('derives input.link from the surviving registration after dedup', () => {
|
||||
const { graph, source, target } = createConnectedGraph()
|
||||
|
||||
const dupLink = injectDuplicateLink(graph, source, target)
|
||||
// Point input.link to the duplicate (simulating corrupted state)
|
||||
target.inputs[0].link = dupLink.id
|
||||
|
||||
graph._removeDuplicateLinks()
|
||||
|
||||
expect(graph._links.size).toBe(1)
|
||||
expect(graph._links.has(dupLink.id)).toBe(false)
|
||||
const survivingId = graph._links.keys().next().value!
|
||||
expect(target.inputs[0].link).toBe(survivingId)
|
||||
expect(graph._links.has(target.inputs[0].link!)).toBe(true)
|
||||
@@ -1003,7 +1125,7 @@ describe('Subgraph Unpacking', () => {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
function duplicateExistingLink(graph: LGraph, source: LGraphNode) {
|
||||
function duplicateExistingLink(graph: LGraph) {
|
||||
const existingLink = graph._links.values().next().value!
|
||||
const linkId = toLinkId(Number(graph.state.lastLinkId) + 1)
|
||||
graph.state.lastLinkId = linkId
|
||||
@@ -1016,7 +1138,6 @@ describe('Subgraph Unpacking', () => {
|
||||
existingLink.target_slot
|
||||
)
|
||||
graph._links.set(dup.id, dup)
|
||||
source.outputs[0].links!.push(dup.id)
|
||||
return dup
|
||||
}
|
||||
|
||||
@@ -1032,7 +1153,7 @@ describe('Subgraph Unpacking', () => {
|
||||
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph, sourceNode)
|
||||
for (let i = 0; i < 3; i++) duplicateExistingLink(subgraph)
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
@@ -1055,7 +1176,7 @@ describe('Subgraph Unpacking', () => {
|
||||
subgraph.add(targetNode)
|
||||
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
duplicateExistingLink(subgraph, sourceNode)
|
||||
duplicateExistingLink(subgraph)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
@@ -1099,7 +1220,6 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
|
||||
const SHARED_NODE_IDS = [3, 8, 37]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.registerNodeType('dummy', DummyNode)
|
||||
})
|
||||
|
||||
|
||||
@@ -8,9 +8,14 @@ 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 { inputHasLink, inputLinkId, outputLinks } from './node/slotLinks'
|
||||
import { useNodeBadgeStore } from '@/stores/nodeBadgeStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { UNASSIGNED_NODE_ID, parseNodeId, toNodeId } from '@/types/nodeId'
|
||||
@@ -20,7 +25,7 @@ import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
import {
|
||||
groupLinksByTuple,
|
||||
purgeOrphanedLinks,
|
||||
repairInputLinks,
|
||||
realignInputLinkSlots,
|
||||
selectSurvivorLink
|
||||
} from './linkDeduplication'
|
||||
|
||||
@@ -29,10 +34,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 +74,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 +108,7 @@ import type {
|
||||
import { getAllNestedItems } from './utils/collections'
|
||||
import {
|
||||
deduplicateSubgraphNodeIds,
|
||||
deduplicateSubgraphRerouteIds,
|
||||
topologicalSortSubgraphs
|
||||
} from './subgraph/subgraphDeduplication'
|
||||
|
||||
@@ -395,6 +412,17 @@ export class LGraph
|
||||
if (this.isRootGraph && graphId !== zeroUuid) {
|
||||
usePreviewExposureStore().clearGraph(graphId)
|
||||
useWidgetValueStore().clearGraph(graphId)
|
||||
useLinkStore().clearGraph(graphId)
|
||||
useRerouteStore().clearGraph(graphId)
|
||||
useNodeBadgeStore().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)
|
||||
for (const node of this._nodes) {
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
|
||||
}
|
||||
}
|
||||
|
||||
this.id = zeroUuid
|
||||
@@ -674,8 +702,8 @@ export class LGraph
|
||||
// num of input connections
|
||||
let num = 0
|
||||
if (node.inputs) {
|
||||
for (const input of node.inputs) {
|
||||
if (input?.link != null) {
|
||||
for (const [slot] of node.inputs.entries()) {
|
||||
if (inputHasLink(this, node.id, slot)) {
|
||||
num += 1
|
||||
}
|
||||
}
|
||||
@@ -705,16 +733,9 @@ export class LGraph
|
||||
if (!node.outputs) continue
|
||||
|
||||
// for every output
|
||||
for (const output of node.outputs) {
|
||||
// not connected
|
||||
// TODO: Confirm functionality, clean condition
|
||||
if (output?.links == null || output.links.length == 0) continue
|
||||
|
||||
for (const [slot] of node.outputs.entries()) {
|
||||
// for every connection
|
||||
for (const link_id of output.links) {
|
||||
const link = this._links.get(link_id)
|
||||
if (!link) continue
|
||||
|
||||
for (const link of outputLinks(this, node.id, slot)) {
|
||||
// already visited link (ignore it)
|
||||
if (visited_links[link.id]) continue
|
||||
|
||||
@@ -1006,9 +1027,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)
|
||||
@@ -1022,6 +1049,10 @@ export class LGraph
|
||||
|
||||
this.onNodeAdded?.(node)
|
||||
|
||||
// After onNodeAdded: this write wakes a watcher, queueing Vue's flush
|
||||
// microtask, which must not overtake onNodeAdded's paste-scan microtask.
|
||||
useNodeBadgeStore().registerNode(this.rootGraph.id, node.id)
|
||||
|
||||
this.setDirtyCanvas(true)
|
||||
this.change()
|
||||
|
||||
@@ -1080,8 +1111,8 @@ export class LGraph
|
||||
|
||||
// disconnect inputs
|
||||
if (inputs) {
|
||||
for (const [i, slot] of inputs.entries()) {
|
||||
if (slot.link != null) node.disconnectInput(i, true)
|
||||
for (const [i] of inputs.entries()) {
|
||||
if (inputHasLink(this, node.id, i)) node.disconnectInput(i, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1112,6 +1143,11 @@ export class LGraph
|
||||
|
||||
if (!hasRemainingReferences) {
|
||||
forEachNode(node.subgraph, fireNodeRemovalLifecycle)
|
||||
forEachNode(node.subgraph, (innerNode) =>
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, innerNode.id)
|
||||
)
|
||||
unregisterAllLinkTopologies(node.subgraph)
|
||||
unregisterAllRerouteChains(node.subgraph)
|
||||
this.rootGraph.subgraphs.delete(node.subgraph.id)
|
||||
}
|
||||
}
|
||||
@@ -1138,6 +1174,7 @@ export class LGraph
|
||||
if (pos != -1) this._nodes.splice(pos, 1)
|
||||
|
||||
delete this._nodes_by_id[node.id]
|
||||
useNodeBadgeStore().unregisterNode(this.rootGraph.id, node.id)
|
||||
|
||||
this.onNodeRemoved?.(node)
|
||||
|
||||
@@ -1422,49 +1459,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 +1523,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 +1558,6 @@ export class LGraph
|
||||
id,
|
||||
parentId,
|
||||
pos,
|
||||
linkIds,
|
||||
floating
|
||||
}: OptionalProps<SerialisableReroute, 'id'>): Reroute {
|
||||
const rerouteId =
|
||||
@@ -1508,11 +1569,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 +1591,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 +1626,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 +1668,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,10 +1706,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))
|
||||
)
|
||||
repairInputLinks(ids, keepId, node)
|
||||
purgeOrphanedLinks(ids, keepId, this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1908,7 +1944,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 +2078,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
|
||||
@@ -2079,7 +2091,7 @@ export class LGraph
|
||||
for (const [, link] of subgraphNode.subgraph._links) {
|
||||
let externalParentId: RerouteId | undefined
|
||||
if (link.origin_id === SUBGRAPH_INPUT_ID) {
|
||||
const outerLinkId = subgraphNode.inputs[link.origin_slot].link
|
||||
const outerLinkId = inputLinkId(this, subgraphNode.id, link.origin_slot)
|
||||
if (!outerLinkId) {
|
||||
console.error('Missing Link ID when unpacking')
|
||||
continue
|
||||
@@ -2100,9 +2112,11 @@ export class LGraph
|
||||
link.origin_id = origin_id
|
||||
}
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_ID) {
|
||||
for (const linkId of subgraphNode.outputs[link.target_slot].links ??
|
||||
[]) {
|
||||
const sublink = this.links[linkId]
|
||||
for (const sublink of outputLinks(
|
||||
this,
|
||||
subgraphNode.id,
|
||||
link.target_slot
|
||||
)) {
|
||||
newLinks.push({
|
||||
oid: link.origin_id,
|
||||
oslot: link.origin_slot,
|
||||
@@ -2200,89 +2214,96 @@ 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
|
||||
|
||||
/**
|
||||
* A reroute chain segment, terminal-first. `complete` is false when the
|
||||
* walk stopped at a broken reference or a cycle; stitching stops there
|
||||
* too.
|
||||
*/
|
||||
interface ChainSegment {
|
||||
segment: RerouteId[]
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
/** Walks a chain of this graph's own reroutes upstream from `start`. */
|
||||
const externalSegment = (start: RerouteId | undefined): 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 reroute = this.reroutes.get(id)
|
||||
if (!reroute) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
return { segment, complete: false }
|
||||
}
|
||||
segment.push(id)
|
||||
id = reroute.parentId
|
||||
}
|
||||
return { segment, complete: true }
|
||||
}
|
||||
|
||||
/** Walks an old subgraph chain from `start`, emitting migrated ids. */
|
||||
const internalSegment = (start: RerouteId | undefined): 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 migratedId = rerouteIdMap.get(id)
|
||||
if (migratedId === undefined) {
|
||||
console.error('Broken Id link when unpacking')
|
||||
return { segment, complete: false }
|
||||
}
|
||||
segment.push(migratedId)
|
||||
id = oldReroutes.get(id)?.parentId
|
||||
}
|
||||
return { segment, complete: true }
|
||||
}
|
||||
|
||||
// Stitch each link's chain from its internal (migrated) and external
|
||||
// segments, ordered by which side was nearest the input.
|
||||
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 = internalSegment(newLink.iparent)
|
||||
const external = externalSegment(newLink.eparent)
|
||||
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 +2492,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 +2515,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 +2556,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 +2610,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 +2693,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2676,6 +2706,12 @@ export class LGraph
|
||||
// without proper cleanup of the previous connection.
|
||||
this._removeDuplicateLinks()
|
||||
|
||||
// Node configure() overrides may have reordered serialized inputs in
|
||||
// place to match current node definitions; re-key links to the slots
|
||||
// that reference them. Uses nodeDataMap: the effective (possibly
|
||||
// deduplicated-clone) data nodes were actually configured from.
|
||||
realignInputLinkSlots(this, nodeDataMap.values())
|
||||
|
||||
// groups
|
||||
this._groups.length = 0
|
||||
const groupData = data.groups
|
||||
|
||||
@@ -3,11 +3,6 @@ import { LGraphIcon } from './LGraphIcon'
|
||||
import type { LGraphIconOptions } from './LGraphIcon'
|
||||
import { cachedMeasureText } from './utils/textMeasureCache'
|
||||
|
||||
export enum BadgePosition {
|
||||
TopLeft = 'top-left',
|
||||
TopRight = 'top-right'
|
||||
}
|
||||
|
||||
export interface LGraphBadgeOptions {
|
||||
text: string
|
||||
fgColor?: string
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ updatePreviews: () => ({}) })
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createSerialisedNode(
|
||||
id: number,
|
||||
type: string,
|
||||
@@ -256,6 +258,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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
@@ -13,6 +15,8 @@ import {
|
||||
LiteGraph
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
const TEST_NODE_TYPE = 'test/CloneZIndex' as const
|
||||
|
||||
class TestNode extends LGraphNode {
|
||||
|
||||
@@ -79,21 +79,18 @@ function createTestLink(
|
||||
targetNode.id,
|
||||
inputSlot
|
||||
)
|
||||
graph._links.set(linkId, link)
|
||||
sourceNode.outputs[outputSlot].links ??= []
|
||||
sourceNode.outputs[outputSlot].links!.push(linkId)
|
||||
targetNode.inputs[inputSlot].link = linkId
|
||||
graph._addLink(link)
|
||||
return link
|
||||
}
|
||||
|
||||
describe('drawConnections widget-input slot positioning', () => {
|
||||
describe('drawConnections', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
let canvasElement: HTMLCanvasElement
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setActivePinia(createTestingPinia())
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
@@ -177,6 +174,31 @@ describe('drawConnections widget-input slot positioning', () => {
|
||||
expect(arrangeSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never reads the deprecated slot link mirrors in a connect-draw-serialize cycle', () => {
|
||||
const deprecationCallback = vi.fn()
|
||||
const originalCallbacks = LiteGraph.onDeprecationWarning
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
try {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.addOutput('out', 'STRING')
|
||||
graph.add(sourceNode)
|
||||
|
||||
const targetNode = new LGraphNode('Target')
|
||||
targetNode.addInput('in', 'STRING')
|
||||
graph.add(targetNode)
|
||||
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
canvas.drawConnections(createMockCtx())
|
||||
graph.asSerialisable()
|
||||
|
||||
expect(deprecationCallback).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
LiteGraph.onDeprecationWarning = originalCallbacks
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
}
|
||||
})
|
||||
|
||||
it('positions widget-input slots when display name differs from slot.widget.name', () => {
|
||||
const sourceNode = new LGraphNode('Source')
|
||||
sourceNode.pos = [0, 100]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -15,6 +17,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createGhostTestHarness() {
|
||||
const canvasElement = document.createElement('canvas')
|
||||
canvasElement.width = 800
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
|
||||
@@ -20,6 +22,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
function createCanvas(graph: LGraph): LGraphCanvas {
|
||||
const el = document.createElement('canvas')
|
||||
el.width = 800
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
@@ -19,6 +21,8 @@ vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
describe('LGraphCanvas slot hit detection', () => {
|
||||
let graph: LGraph
|
||||
let canvas: LGraphCanvas
|
||||
|
||||
@@ -26,7 +26,13 @@ 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 {
|
||||
inputHasLink,
|
||||
inputLinkId,
|
||||
outputLinkIds,
|
||||
outputLinks
|
||||
} from './node/slotLinks'
|
||||
import type { LinkId } from './LLink'
|
||||
import { Reroute } from './Reroute'
|
||||
import type { RerouteId } from './Reroute'
|
||||
@@ -62,7 +68,6 @@ import type {
|
||||
INodeSlot,
|
||||
INodeSlotContextItem,
|
||||
ISlotType,
|
||||
LinkNetwork,
|
||||
LinkSegment,
|
||||
NewNodePosition,
|
||||
NullableProperties,
|
||||
@@ -2780,28 +2785,14 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
} else if (!node.flags.collapsed) {
|
||||
const { inputs, outputs } = node
|
||||
|
||||
function hasRelevantOutputLinks(
|
||||
output: INodeOutputSlot,
|
||||
network: LinkNetwork
|
||||
): boolean {
|
||||
const outputLinks = [
|
||||
...(output.links ?? []),
|
||||
...[...(output._floatingLinks ?? new Set())]
|
||||
]
|
||||
return outputLinks.some(
|
||||
(linkId) =>
|
||||
typeof linkId === 'number' && network.getLink(linkId) !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
// Outputs
|
||||
if (outputs) {
|
||||
for (const [i, output] of outputs.entries()) {
|
||||
const link_pos = node.getOutputPos(i)
|
||||
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)
|
||||
if (e.shiftKey && outputLinks(graph, node.id, i).length > 0) {
|
||||
linkConnector.moveOutputLink(graph, node, output)
|
||||
this._linkConnectorDrop()
|
||||
return
|
||||
}
|
||||
@@ -2847,12 +2838,15 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
ctrlOrMeta &&
|
||||
e.altKey &&
|
||||
!e.shiftKey
|
||||
if (input.link !== null || input._floatingLinks?.size) {
|
||||
if (
|
||||
inputHasLink(graph, node.id, i) ||
|
||||
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 +4288,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4608,16 +4602,19 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
this.onNodeSelected?.(item)
|
||||
|
||||
// Highlight links
|
||||
if (item.inputs) {
|
||||
for (const input of item.inputs) {
|
||||
if (input.link == null) continue
|
||||
this.highlighted_links[input.link] = true
|
||||
const { graph: highlightGraph } = this
|
||||
if (item.inputs && highlightGraph) {
|
||||
for (const [i] of item.inputs.entries()) {
|
||||
const linkId = inputLinkId(highlightGraph, item.id, i)
|
||||
if (linkId == null) continue
|
||||
this.highlighted_links[linkId] = true
|
||||
}
|
||||
}
|
||||
if (item.outputs) {
|
||||
for (const id of item.outputs.flatMap((x) => x.links)) {
|
||||
if (id == null) continue
|
||||
this.highlighted_links[id] = true
|
||||
if (item.outputs && highlightGraph) {
|
||||
for (const [i] of item.outputs.entries()) {
|
||||
for (const id of outputLinkIds(highlightGraph, item.id, i)) {
|
||||
this.highlighted_links[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4664,19 +4661,20 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// Clear link highlight
|
||||
if (item.inputs) {
|
||||
for (const input of item.inputs) {
|
||||
if (input.link == null) continue
|
||||
for (const [i] of item.inputs.entries()) {
|
||||
const linkId = inputLinkId(graph, item.id, i)
|
||||
if (linkId == null) continue
|
||||
|
||||
const node = LLink.getOriginNode(graph, input.link)
|
||||
const node = LLink.getOriginNode(graph, linkId)
|
||||
if (node && this.selectedItems.has(node)) continue
|
||||
|
||||
delete this.highlighted_links[input.link]
|
||||
delete this.highlighted_links[linkId]
|
||||
}
|
||||
}
|
||||
if (item.outputs) {
|
||||
for (const id of item.outputs.flatMap((x) => x.links)) {
|
||||
if (id == null) continue
|
||||
|
||||
for (const id of item.outputs.flatMap((_x, i) =>
|
||||
outputLinkIds(graph, item.id, i)
|
||||
)) {
|
||||
const node = LLink.getTargetNode(graph, id)
|
||||
if (node && this.selectedItems.has(node)) continue
|
||||
|
||||
@@ -4802,14 +4800,18 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (oldNode) this.selected_nodes[oldNode.id] = oldNode
|
||||
|
||||
// Highlight links
|
||||
if (keepSelected.inputs) {
|
||||
for (const input of keepSelected.inputs) {
|
||||
if (input.link == null) continue
|
||||
this.highlighted_links[input.link] = true
|
||||
const { graph: rehighlightGraph } = this
|
||||
if (keepSelected.inputs && rehighlightGraph) {
|
||||
for (const [i] of keepSelected.inputs.entries()) {
|
||||
const linkId = inputLinkId(rehighlightGraph, keepSelected.id, i)
|
||||
if (linkId == null) continue
|
||||
this.highlighted_links[linkId] = true
|
||||
}
|
||||
}
|
||||
if (keepSelected.outputs) {
|
||||
for (const id of keepSelected.outputs.flatMap((x) => x.links)) {
|
||||
if (keepSelected.outputs && rehighlightGraph) {
|
||||
for (const id of keepSelected.outputs.flatMap((_x, i) =>
|
||||
outputLinkIds(rehighlightGraph, keepSelected.id, i)
|
||||
)) {
|
||||
if (id == null) continue
|
||||
this.highlighted_links[id] = true
|
||||
}
|
||||
@@ -6042,48 +6044,41 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
node.arrange()
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
// for every input (we render just inputs because it is easier as every slot can only have one input)
|
||||
const { inputs } = node
|
||||
if (!inputs?.length) continue
|
||||
// Render every link at its target input (each input holds at most one link).
|
||||
for (const link of graph._links.values()) {
|
||||
const node = graph.getNodeById(link.target_id)
|
||||
const input = node?.inputs[link.target_slot]
|
||||
if (!node || !input) continue
|
||||
|
||||
for (const [i, input] of inputs.entries()) {
|
||||
if (!input || input.link == null) continue
|
||||
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(node, link.target_slot, true)
|
||||
: node.getInputPos(link.target_slot)
|
||||
|
||||
const link_id = input.link
|
||||
const link = graph._links.get(link_id)
|
||||
if (!link) continue
|
||||
// find link info
|
||||
const start_node = graph.getNodeById(link.origin_id)
|
||||
if (start_node == null) continue
|
||||
|
||||
const endPos: Point = LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(node, i, true)
|
||||
: node.getInputPos(i)
|
||||
const outputId = link.origin_slot
|
||||
const startPos: Point =
|
||||
outputId === -1
|
||||
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
|
||||
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(start_node, outputId, false)
|
||||
: start_node.getOutputPos(outputId)
|
||||
|
||||
// find link info
|
||||
const start_node = graph.getNodeById(link.origin_id)
|
||||
if (start_node == null) continue
|
||||
const output = start_node.outputs[outputId]
|
||||
if (!output) continue
|
||||
|
||||
const outputId = link.origin_slot
|
||||
const startPos: Point =
|
||||
outputId === -1
|
||||
? [start_node.pos[0] + 10, start_node.pos[1] + 10]
|
||||
: LiteGraph.vueNodesMode // TODO: still use LG get pos if vue nodes is off until stable
|
||||
? getSlotPosition(start_node, outputId, false)
|
||||
: start_node.getOutputPos(outputId)
|
||||
|
||||
const output = start_node.outputs[outputId]
|
||||
if (!output) continue
|
||||
|
||||
this._renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
endPos,
|
||||
visibleReroutes,
|
||||
now,
|
||||
output.dir,
|
||||
input.dir
|
||||
)
|
||||
}
|
||||
this._renderAllLinkSegments(
|
||||
ctx,
|
||||
link,
|
||||
startPos,
|
||||
endPos,
|
||||
visibleReroutes,
|
||||
now,
|
||||
output.dir,
|
||||
input.dir
|
||||
)
|
||||
}
|
||||
|
||||
if (subgraph) {
|
||||
@@ -8691,7 +8686,10 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (node.getSlotMenuOptions) {
|
||||
menu_info = node.getSlotMenuOptions(slot)
|
||||
} else {
|
||||
if (slot.output?.links?.length || slot.input?.link != null) {
|
||||
const slotConnected = slot.output
|
||||
? node.isOutputConnected(slot.slot)
|
||||
: node.isInputConnected(slot.slot)
|
||||
if (slotConnected) {
|
||||
menu_info.push({ content: 'Disconnect Links', slot })
|
||||
}
|
||||
|
||||
@@ -8701,7 +8699,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
'Both in put and output slots were null when processing context menu.'
|
||||
)
|
||||
|
||||
if (!_slot.nameLocked && !('link' in _slot && _slot.widget)) {
|
||||
if (!_slot.nameLocked && !slot.input?.widget) {
|
||||
menu_info.push({ content: 'Rename Slot', slot })
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { test } from './__fixtures__/testExtensions'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
import { createMockLGraphNodeWithArrayBoundingRect } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
@@ -95,7 +96,7 @@ describe('LGraphNode', () => {
|
||||
outputs: node.outputs?.map((o) => ({
|
||||
name: o.name,
|
||||
type: o.type,
|
||||
links: o.links,
|
||||
links: o.links ? [...o.links] : o.links,
|
||||
slot_index: o.slot_index
|
||||
}))
|
||||
}
|
||||
@@ -134,7 +135,7 @@ describe('LGraphNode', () => {
|
||||
expect(node.outputs.length).toEqual(1)
|
||||
expect(node.outputs[0].name).toEqual('TestOutput')
|
||||
expect(node.outputs[0].type).toEqual('number')
|
||||
expect(node.outputs[0].links).toEqual([])
|
||||
expect(node.outputs[0].links).toBeNull()
|
||||
expect(node.outputs[0]).instanceOf(NodeOutputSlot)
|
||||
|
||||
// Should not override existing outputs
|
||||
@@ -184,7 +185,7 @@ describe('LGraphNode', () => {
|
||||
const disconnected = node2.disconnectInput(0)
|
||||
expect(disconnected).toBe(true)
|
||||
expect(node2.inputs[0].link).toBeNull()
|
||||
expect(node1.outputs[0].links?.length).toBe(0)
|
||||
expect(node1.outputs[0].links).toBeNull()
|
||||
expect(graph._links.has(link!.id)).toBe(false)
|
||||
|
||||
// Test disconnecting by slot name
|
||||
@@ -260,7 +261,7 @@ describe('LGraphNode', () => {
|
||||
)
|
||||
expect(disconnectedByName).toBe(true)
|
||||
expect(targetNode1.inputs[0].link).toBeNull()
|
||||
expect(sourceNode.outputs[1].links?.length).toBe(0)
|
||||
expect(sourceNode.outputs[1].links).toBeNull()
|
||||
|
||||
// Test disconnecting all connections from an output
|
||||
const link4 = sourceNode.connect(0, targetNode1, 0)
|
||||
@@ -282,6 +283,58 @@ describe('LGraphNode', () => {
|
||||
const alreadyDisconnected = sourceNode.disconnectOutput(0)
|
||||
expect(alreadyDisconnected).toBe(false)
|
||||
})
|
||||
|
||||
function createConnectedPair() {
|
||||
const sourceNode = new LGraphNode('SourceNode')
|
||||
const targetNode = new LGraphNode('TargetNode')
|
||||
sourceNode.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 1,
|
||||
outputs: [{ name: 'Output1', type: 'number', links: [] }]
|
||||
})
|
||||
)
|
||||
targetNode.configure(
|
||||
getMockISerialisedNode({
|
||||
id: 2,
|
||||
inputs: [{ name: 'Input1', type: 'number', link: null }]
|
||||
})
|
||||
)
|
||||
const graph = new LGraph()
|
||||
graph.add(sourceNode)
|
||||
graph.add(targetNode)
|
||||
expect(sourceNode.connect(0, targetNode, 0)).not.toBeNull()
|
||||
return { graph, sourceNode, targetNode }
|
||||
}
|
||||
|
||||
function observeInputDisconnects(targetNode: LGraphNode) {
|
||||
const observed: { connected: boolean; link: unknown }[] = []
|
||||
targetNode.onConnectionsChange = function (type, slot, isConnected) {
|
||||
if (type !== NodeSlotType.INPUT || isConnected) return
|
||||
observed.push({
|
||||
connected: this.isInputConnected(slot),
|
||||
link: this.getInputLink(slot)
|
||||
})
|
||||
}
|
||||
return observed
|
||||
}
|
||||
|
||||
test('target sees disconnected state inside onConnectionsChange when all output links are removed', () => {
|
||||
const { sourceNode, targetNode } = createConnectedPair()
|
||||
const observed = observeInputDisconnects(targetNode)
|
||||
|
||||
expect(sourceNode.disconnectOutput(0)).toBe(true)
|
||||
|
||||
expect(observed).toEqual([{ connected: false, link: null }])
|
||||
})
|
||||
|
||||
test('target sees disconnected state inside onConnectionsChange when the source node is removed', () => {
|
||||
const { graph, sourceNode, targetNode } = createConnectedPair()
|
||||
const observed = observeInputDisconnects(targetNode)
|
||||
|
||||
graph.remove(sourceNode)
|
||||
|
||||
expect(observed).toEqual([{ connected: false, link: null }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Applies correct link type on connection', () => {
|
||||
|
||||
245
src/lib/litegraph/src/LLink.store.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
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('keeps both links registered when two target slots are swapped', () => {
|
||||
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 first = a.connect(0, b, 0)!
|
||||
const second = a.connect(0, b, 1)!
|
||||
|
||||
first.target_slot = 1
|
||||
second.target_slot = 0
|
||||
|
||||
const store = useLinkStore()
|
||||
const graphId = graph.rootGraph.id
|
||||
expect(b.isInputConnected(0)).toBe(true)
|
||||
expect(b.isInputConnected(1)).toBe(true)
|
||||
expect(store.getInputSlotLink(graphId, b.id, 0)?.id).toBe(second.id)
|
||||
expect(store.getInputSlotLink(graphId, b.id, 1)?.id).toBe(first.id)
|
||||
})
|
||||
|
||||
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,88 @@ 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) {
|
||||
useLinkStore().updateEndpoint(link._graphId, link._state, patch)
|
||||
} 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 +233,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 +532,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 +571,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)
|
||||
}
|
||||
|
||||
187
src/lib/litegraph/src/Reroute.store.test.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
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('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,53 @@ 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.wouldCreateParentCycle(value)) return
|
||||
this._chain.parentId = value
|
||||
}
|
||||
|
||||
/** Walks the proposed parent chain, checking it never returns to this reroute. */
|
||||
private wouldCreateParentCycle(parentId: RerouteId): boolean {
|
||||
const reroutes = this.network.deref()?.reroutes
|
||||
const visited = new Set<RerouteId>([this.id])
|
||||
let nextId: RerouteId | undefined = parentId
|
||||
|
||||
while (nextId !== undefined) {
|
||||
if (visited.has(nextId)) return true
|
||||
visited.add(nextId)
|
||||
nextId = 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 +149,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 +243,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 +267,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 +294,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 +385,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 +419,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 +645,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 +779,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)
|
||||
}
|
||||
|
||||
15
src/lib/litegraph/src/badgeIconRegistry.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { LGraphIconOptions } from './LGraphIcon'
|
||||
|
||||
const icons = new Map<string, LGraphIconOptions>()
|
||||
|
||||
/** Maps a {@link BadgeData.iconKey} to canvas icon options. */
|
||||
export function registerBadgeIcon(
|
||||
key: string,
|
||||
options: LGraphIconOptions
|
||||
): void {
|
||||
icons.set(key, options)
|
||||
}
|
||||
|
||||
export function resolveBadgeIcon(key: string): LGraphIconOptions | undefined {
|
||||
return icons.get(key)
|
||||
}
|
||||
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()
|
||||
@@ -46,7 +53,7 @@ const test = baseTest.extend<TestContext>({
|
||||
const reroutes = new Map<RerouteId, Reroute>()
|
||||
|
||||
await use({
|
||||
links: new Map<LinkId, LLink>(),
|
||||
links: graph._links,
|
||||
reroutes,
|
||||
floatingLinks,
|
||||
getLink: graph.getLink.bind(graph),
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -125,11 +134,10 @@ describe('LinkConnector', () => {
|
||||
sourceNode.addOutput('out', slotType)
|
||||
targetNode.addInput('in', slotType)
|
||||
|
||||
const link = new LLink(toLinkId(1), slotType, 1, 0, 2, 0)
|
||||
network.links.set(link.id, link)
|
||||
targetNode.inputs[0].link = link.id
|
||||
const link = sourceNode.connect(0, targetNode, 0)!
|
||||
expect(link).toBeTruthy()
|
||||
|
||||
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 +154,7 @@ describe('LinkConnector', () => {
|
||||
expect(() => {
|
||||
connector.moveInputLink(
|
||||
network,
|
||||
new LGraphNode('mock'),
|
||||
createMockNodeInputSlot({ link: toLinkId(1) })
|
||||
)
|
||||
}).toThrow('Already dragging links.')
|
||||
@@ -165,11 +174,10 @@ describe('LinkConnector', () => {
|
||||
sourceNode.addOutput('out', slotType)
|
||||
targetNode.addInput('in', slotType)
|
||||
|
||||
const link = new LLink(toLinkId(1), slotType, 1, 0, 2, 0)
|
||||
network.links.set(link.id, link)
|
||||
sourceNode.outputs[0].links = [link.id]
|
||||
const link = sourceNode.connect(0, targetNode, 0)!
|
||||
expect(link).toBeTruthy()
|
||||
|
||||
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 +195,7 @@ describe('LinkConnector', () => {
|
||||
expect(() => {
|
||||
connector.moveOutputLink(
|
||||
network,
|
||||
new LGraphNode('mock'),
|
||||
createMockNodeOutputSlot({ links: [toLinkId(1)] })
|
||||
)
|
||||
}).toThrow('Already dragging links.')
|
||||
@@ -241,11 +250,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)
|
||||
@@ -428,7 +449,7 @@ describe('LinkConnector Integration', () => {
|
||||
expect(connector.outputLinks.length).toBe(0)
|
||||
|
||||
expect(disconnectedNode.outputs[0].links).toEqual(nextLinkIds)
|
||||
expect(hasOutputNode.outputs[0].links).toEqual([])
|
||||
expect(hasOutputNode.outputs[0].links).toBeNull()
|
||||
|
||||
const reroutesAfter = disconnectedNode.outputs[0].links
|
||||
?.map((linkId) => graph.links.get(linkId)!)
|
||||
@@ -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,11 +585,15 @@ describe('LinkConnector Integration', () => {
|
||||
canvasY
|
||||
)
|
||||
|
||||
connector.moveOutputLink(graph, manyOutputsNode.outputs[0])
|
||||
connector.moveOutputLink(
|
||||
graph,
|
||||
manyOutputsNode,
|
||||
manyOutputsNode.outputs[0]
|
||||
)
|
||||
connector.dropLinks(graph, floatingRerouteEvent)
|
||||
connector.reset()
|
||||
|
||||
expect(manyOutputsNode.outputs[0].links).toEqual([])
|
||||
expect(manyOutputsNode.outputs[0].links).toBeNull()
|
||||
expect(floatingReroute.linkIds.size).toBe(4)
|
||||
|
||||
validateIntegrityFloatingRemoved()
|
||||
@@ -585,7 +614,7 @@ describe('LinkConnector Integration', () => {
|
||||
const canvasY = reroute7.pos[1]
|
||||
const reroute7Event = createMockCanvasPointerEvent(canvasX, canvasY)
|
||||
|
||||
const toSortedRerouteChain = (linkIds: number[]) =>
|
||||
const toSortedRerouteChain = (linkIds: readonly number[]) =>
|
||||
linkIds
|
||||
.map((x) => graph.links.get(toLinkId(x))!)
|
||||
.map((x) => LLink.getReroutes(graph, x))
|
||||
@@ -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,8 @@
|
||||
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 { inputLinkId, outputLinks } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import type { Reroute } from '@/lib/litegraph/src/Reroute'
|
||||
import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
@@ -135,6 +136,7 @@ export class LinkConnector {
|
||||
/** Drag an existing link to a different input. */
|
||||
moveInputLink(
|
||||
network: LinkNetwork,
|
||||
node: LGraphNode,
|
||||
input: INodeInputSlot,
|
||||
opts?: { startPoint?: Point }
|
||||
): void {
|
||||
@@ -142,10 +144,17 @@ export class LinkConnector {
|
||||
|
||||
const { state, inputLinks, renderLinks } = this
|
||||
|
||||
const linkId = input.link
|
||||
const linkId = node.graph
|
||||
? (inputLinkId(node.graph, node.id, node.inputs.indexOf(input)) ?? null)
|
||||
: null
|
||||
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,51 +279,59 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Normal links
|
||||
if (output.links?.length) {
|
||||
for (const linkId of output.links) {
|
||||
const link = network.links.get(linkId)
|
||||
if (!link) continue
|
||||
|
||||
if (node.graph) {
|
||||
for (const link of outputLinks(
|
||||
node.graph,
|
||||
node.id,
|
||||
node.outputs.indexOf(output)
|
||||
)) {
|
||||
const firstReroute = LLink.getFirstReroute(network, link)
|
||||
if (firstReroute) {
|
||||
firstReroute._dragging = true
|
||||
@@ -806,16 +823,20 @@ 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. Resolve the source and each link's validity once,
|
||||
// before connecting: re-anchoring the first link's chain onto the reroute
|
||||
// changes its membership and origin immediately.
|
||||
const result = reroute.findSourceOutput()
|
||||
if (!result) return
|
||||
const { node, output } = result
|
||||
|
||||
const connectable = this.renderLinks.filter(
|
||||
(link) =>
|
||||
link.toType === 'output' &&
|
||||
link.canConnectToReroute(reroute) &&
|
||||
link.canConnectToOutput(node, output)
|
||||
)
|
||||
for (const link of connectable) {
|
||||
link.connectToRerouteOutput(reroute, node, output, this.events)
|
||||
}
|
||||
}
|
||||
@@ -832,20 +853,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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -363,7 +360,13 @@ export interface IWidgetLocator {
|
||||
}
|
||||
|
||||
export interface INodeInputSlot extends INodeSlot {
|
||||
link: LinkId | null
|
||||
/**
|
||||
* @deprecated Id of the link targeting this slot, derived from the link
|
||||
* store by a warning getter. Read via `node.isInputConnected(slot)` /
|
||||
* `node.getInputLink(slot)`; mutate via `node.connect()` /
|
||||
* `node.disconnectInput()`.
|
||||
*/
|
||||
readonly link?: LinkId | null
|
||||
widget?: IWidgetLocator
|
||||
widgetId?: WidgetId
|
||||
alwaysVisible?: boolean
|
||||
@@ -379,7 +382,13 @@ export interface IWidgetInputSlot extends INodeInputSlot {
|
||||
}
|
||||
|
||||
export interface INodeOutputSlot extends INodeSlot {
|
||||
links: LinkId[] | null
|
||||
/**
|
||||
* @deprecated Ids of the links leaving this slot, derived from the link
|
||||
* store by a warning getter. Read via `node.isOutputConnected(slot)` /
|
||||
* `node.getOutputNodes(slot)`; mutate via `node.connect()` /
|
||||
* `node.disconnectOutput()`.
|
||||
*/
|
||||
readonly links?: readonly LinkId[] | null
|
||||
_data?: unknown
|
||||
slot_index?: SlotIndex
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { registerLinkTopology } from './LLink'
|
||||
import { inputLinkId } from './node/slotLinks'
|
||||
|
||||
import type { LGraph } from './LGraph'
|
||||
import type { LGraphNode } from './LGraphNode'
|
||||
import type { SerializedNodeId } from '@/types/nodeId'
|
||||
import type { LLink, LinkId } from './LLink'
|
||||
import type { ISerialisedNode } from './types/serialisation'
|
||||
|
||||
/** Generates a unique string key for a link's connection tuple. */
|
||||
function linkTupleKey(link: LLink): string {
|
||||
@@ -29,55 +34,60 @@ export function selectSurvivorLink(
|
||||
ids: LinkId[],
|
||||
node: LGraphNode | null
|
||||
): LinkId {
|
||||
if (!node) return ids[0]
|
||||
if (!node?.graph) return ids[0]
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (!input) continue
|
||||
const match = ids.find((id) => input.link === id)
|
||||
if (match != null) return match
|
||||
for (const [index] of (node.inputs ?? []).entries()) {
|
||||
const registered = inputLinkId(node.graph, node.id, index)
|
||||
if (registered != null && ids.includes(registered)) return registered
|
||||
}
|
||||
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 output = originNode?.outputs?.[link.origin_slot]
|
||||
if (output?.links) {
|
||||
for (let i = output.links.length - 1; i >= 0; i--) {
|
||||
if (output.links[i] === id) output.links.splice(i, 1)
|
||||
}
|
||||
}
|
||||
|
||||
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. */
|
||||
export function repairInputLinks(
|
||||
ids: LinkId[],
|
||||
keepId: LinkId,
|
||||
node: LGraphNode | null
|
||||
/**
|
||||
* Re-points each link's `target_slot` at the index of the serialized input
|
||||
* that references it. Node `configure()` overrides may reorder a node's
|
||||
* serialized inputs in place to match the current node definition (e.g.
|
||||
* widget-to-input conversions, Comfy-Org/ComfyUI_frontend#3348), invalidating
|
||||
* the slot indices stored on links.
|
||||
*
|
||||
* @param graph The graph whose links to realign
|
||||
* @param nodesData The serialized node data the graph's nodes were configured
|
||||
* from, after any in-place input reordering by node `configure()` overrides
|
||||
*/
|
||||
export function realignInputLinkSlots(
|
||||
graph: LGraph,
|
||||
nodesData: Iterable<ISerialisedNode>
|
||||
): void {
|
||||
if (!node) return
|
||||
|
||||
const duplicateIds = new Set(ids)
|
||||
|
||||
for (const input of node.inputs ?? []) {
|
||||
if (input?.link == null || input.link === keepId) continue
|
||||
if (duplicateIds.has(input.link)) {
|
||||
input.link = keepId
|
||||
for (const nodeData of nodesData) {
|
||||
for (const [slot, input] of (nodeData.inputs ?? []).entries()) {
|
||||
if (input.link == null) continue
|
||||
const link = graph._links.get(toLinkId(input.link))
|
||||
if (link && link.target_slot !== slot) link.target_slot = slot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ export {
|
||||
type SubgraphId
|
||||
} from './LGraph'
|
||||
export type { LGraphTriggerEvent } from './types/graphTriggers'
|
||||
export { BadgePosition, LGraphBadge } from './LGraphBadge'
|
||||
export { LGraphBadge } from './LGraphBadge'
|
||||
export { LGraphCanvas } from './LGraphCanvas'
|
||||
export { LGraphGroup, type GroupId } from './LGraphGroup'
|
||||
export { LGraphNode } from './LGraphNode'
|
||||
|
||||
@@ -9,6 +9,8 @@ import type {
|
||||
} from '@/lib/litegraph/src/interfaces'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import { inputHasLink, inputLinkId } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
@@ -16,7 +18,8 @@ import { isSubgraphInput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
link: LinkId | null
|
||||
/** @deprecated Derived from the link store via a warning prototype getter; never written. */
|
||||
declare readonly link?: LinkId | null
|
||||
alwaysVisible?: boolean
|
||||
|
||||
get isWidgetInputSlot(): boolean {
|
||||
@@ -42,12 +45,17 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
slot: OptionalProps<INodeInputSlot, 'boundingRect'>,
|
||||
node: LGraphNode
|
||||
) {
|
||||
super(slot, node)
|
||||
this.link = slot.link
|
||||
// Serialized inputs carry a legacy link mirror; strip it so the base
|
||||
// ctor's Object.assign cannot collide with the deprecated prototype
|
||||
// getter (assigning a getter-only property throws in strict mode).
|
||||
const { link: _legacyLink, ...rest } = slot
|
||||
super(rest, node)
|
||||
}
|
||||
|
||||
override get isConnected(): boolean {
|
||||
return this.link != null
|
||||
const { graph } = this._node
|
||||
if (!graph) return false
|
||||
return inputHasLink(graph, this._node.id, this._node.inputs.indexOf(this))
|
||||
}
|
||||
|
||||
override isValidTarget(
|
||||
@@ -81,10 +89,40 @@ export class NodeInputSlot extends NodeSlot implements INodeInputSlot {
|
||||
}
|
||||
|
||||
override toJSON(): INodeInputSlot {
|
||||
const { graph } = this._node
|
||||
return {
|
||||
...super.toJSON(),
|
||||
link: this.link,
|
||||
link: graph
|
||||
? (inputLinkId(graph, this._node.id, this._node.inputs.indexOf(this)) ??
|
||||
null)
|
||||
: null,
|
||||
widget: this.widget
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecation telemetry for extensions that still touch `input.link`.
|
||||
* Reads return the store-derived link id; writes fire telemetry and are
|
||||
* ignored, since the store cannot be mutated through the mirror.
|
||||
* First-party code uses the slotLinks helpers.
|
||||
*/
|
||||
Object.defineProperty(NodeInputSlot.prototype, 'link', {
|
||||
get(this: NodeInputSlot): LinkId | null {
|
||||
warnDeprecated(
|
||||
'input.link is deprecated. Read connectivity via node.isInputConnected(slot) / node.getInputLink(slot); mutate via node.connect() / node.disconnectInput().'
|
||||
)
|
||||
const { graph } = this._node
|
||||
if (!graph) return null
|
||||
return (
|
||||
inputLinkId(graph, this._node.id, this._node.inputs.indexOf(this)) ?? null
|
||||
)
|
||||
},
|
||||
set(this: NodeInputSlot): void {
|
||||
warnDeprecated(
|
||||
'Assignment to input.link is deprecated and has no effect; connectivity is derived from the link store. Mutate via node.connect() / node.disconnectInput().'
|
||||
)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
|
||||
@@ -10,12 +10,18 @@ import type {
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import type { IDrawOptions } from '@/lib/litegraph/src/node/NodeSlot'
|
||||
import {
|
||||
outputHasLinks,
|
||||
outputLinkIds
|
||||
} from '@/lib/litegraph/src/node/slotLinks'
|
||||
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
|
||||
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
|
||||
import { isSubgraphOutput } from '@/lib/litegraph/src/subgraph/subgraphUtils'
|
||||
import { warnDeprecated } from '@/lib/litegraph/src/utils/feedback'
|
||||
|
||||
export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
links: LinkId[] | null
|
||||
/** @deprecated Derived from the link store via a warning prototype getter; never written. */
|
||||
declare readonly links?: readonly LinkId[] | null
|
||||
_data?: unknown
|
||||
slot_index?: number
|
||||
|
||||
@@ -34,8 +40,11 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
slot: OptionalProps<INodeOutputSlot, 'boundingRect'>,
|
||||
node: LGraphNode
|
||||
) {
|
||||
super(slot, node)
|
||||
this.links = slot.links
|
||||
// Serialized outputs carry a legacy links mirror; strip it so the base
|
||||
// ctor's Object.assign cannot collide with the deprecated prototype
|
||||
// getter (assigning a getter-only property throws in strict mode).
|
||||
const { links: _legacyLinks, ...rest } = slot
|
||||
super(rest, node)
|
||||
this._data = slot._data
|
||||
this.slot_index = slot.slot_index
|
||||
}
|
||||
@@ -55,7 +64,13 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
}
|
||||
|
||||
override get isConnected(): boolean {
|
||||
return this.links != null && this.links.length > 0
|
||||
const { graph } = this._node
|
||||
if (!graph) return false
|
||||
return outputHasLinks(
|
||||
graph,
|
||||
this._node.id,
|
||||
this._node.outputs.indexOf(this)
|
||||
)
|
||||
}
|
||||
|
||||
override draw(
|
||||
@@ -77,10 +92,43 @@ export class NodeOutputSlot extends NodeSlot implements INodeOutputSlot {
|
||||
}
|
||||
|
||||
override toJSON(): INodeOutputSlot {
|
||||
const { graph } = this._node
|
||||
const ids = graph
|
||||
? outputLinkIds(graph, this._node.id, this._node.outputs.indexOf(this))
|
||||
: []
|
||||
return {
|
||||
...super.toJSON(),
|
||||
links: this.links,
|
||||
links: ids.length ? ids : null,
|
||||
slot_index: this.slot_index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecation telemetry for extensions that still touch `output.links`.
|
||||
* Reads return a fresh store-derived array; writes fire telemetry and are
|
||||
* ignored, since the store cannot be mutated through the mirror.
|
||||
* First-party code uses the slotLinks helpers.
|
||||
*/
|
||||
Object.defineProperty(NodeOutputSlot.prototype, 'links', {
|
||||
get(this: NodeOutputSlot): LinkId[] | null {
|
||||
warnDeprecated(
|
||||
'output.links is deprecated. Read connectivity via node.isOutputConnected(slot) / node.getOutputNodes(slot); mutate via node.connect() / node.disconnectOutput().'
|
||||
)
|
||||
const { graph } = this._node
|
||||
if (!graph) return null
|
||||
const ids = outputLinkIds(
|
||||
graph,
|
||||
this._node.id,
|
||||
this._node.outputs.indexOf(this)
|
||||
)
|
||||
return ids.length ? ids : null
|
||||
},
|
||||
set(this: NodeOutputSlot): void {
|
||||
warnDeprecated(
|
||||
'Assignment to output.links is deprecated and has no effect; connectivity is derived from the link store. Mutate via node.connect() / node.disconnectOutput().'
|
||||
)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: false
|
||||
})
|
||||
|
||||
123
src/lib/litegraph/src/node/NodeSlot.deprecation.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LinkId } from '@/lib/litegraph/src/LLink'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
function createConnectedPair() {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'INT')
|
||||
const target = new LGraphNode('Target')
|
||||
target.addInput('in', 'INT')
|
||||
graph.add(source)
|
||||
graph.add(target)
|
||||
const link = source.connect(0, target, 0)!
|
||||
return { graph, source, target, link }
|
||||
}
|
||||
|
||||
describe('deprecated slot link mirrors', () => {
|
||||
const deprecationCallback = vi.fn()
|
||||
const originalCallbacks = LiteGraph.onDeprecationWarning
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
deprecationCallback.mockClear()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.onDeprecationWarning = originalCallbacks
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
})
|
||||
|
||||
describe('NodeInputSlot.link', () => {
|
||||
it('returns the link id for a connected input and warns', () => {
|
||||
const { target, link } = createConnectedPair()
|
||||
|
||||
expect(target.inputs[0].link).toBe(link.id)
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('input.link is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a disconnected input and warns', () => {
|
||||
const { target } = createConnectedPair()
|
||||
target.disconnectInput(0)
|
||||
|
||||
expect(target.inputs[0].link).toBeNull()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('input.link is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an input on a node detached from any graph', () => {
|
||||
const node = new LGraphNode('Detached')
|
||||
node.addInput('in', 'INT')
|
||||
|
||||
expect(node.inputs[0].link).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores writes, warns, and keeps the store-derived value', () => {
|
||||
const { target, link } = createConnectedPair()
|
||||
const input: { link?: LinkId | null } = target.inputs[0]
|
||||
|
||||
expect(() => {
|
||||
input.link = null
|
||||
}).not.toThrow()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Assignment to input.link is deprecated'),
|
||||
undefined
|
||||
)
|
||||
expect(target.inputs[0].link).toBe(link.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NodeOutputSlot.links', () => {
|
||||
it('returns the link ids for a connected output and warns', () => {
|
||||
const { source, link } = createConnectedPair()
|
||||
|
||||
expect(source.outputs[0].links).toEqual([link.id])
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output.links is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for a disconnected output and warns', () => {
|
||||
const { source } = createConnectedPair()
|
||||
source.disconnectOutput(0)
|
||||
|
||||
expect(source.outputs[0].links).toBeNull()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('output.links is deprecated'),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('returns null for an output on a node detached from any graph', () => {
|
||||
const node = new LGraphNode('Detached')
|
||||
node.addOutput('out', 'INT')
|
||||
|
||||
expect(node.outputs[0].links).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores writes, warns, and keeps the store-derived value', () => {
|
||||
const { source, link } = createConnectedPair()
|
||||
const output: { links?: readonly LinkId[] | null } = source.outputs[0]
|
||||
|
||||
expect(() => {
|
||||
output.links = null
|
||||
}).not.toThrow()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Assignment to output.links is deprecated'),
|
||||
undefined
|
||||
)
|
||||
expect(source.outputs[0].links).toEqual([link.id])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot
|
||||
INodeOutputSlot,
|
||||
IWidget
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
LGraphNode,
|
||||
inputAsSerialisable,
|
||||
outputAsSerialisable
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
@@ -22,12 +24,17 @@ describe('NodeSlot', () => {
|
||||
links: [],
|
||||
boundingRect
|
||||
}
|
||||
// @ts-expect-error Argument type mismatch for test
|
||||
const serialized = outputAsSerialisable(slot)
|
||||
const node = new LGraphNode('test')
|
||||
const serialized = outputAsSerialisable(
|
||||
slot as INodeOutputSlot & { widget?: IWidget },
|
||||
node,
|
||||
0
|
||||
)
|
||||
expect(serialized).not.toHaveProperty('_data')
|
||||
})
|
||||
|
||||
it('removes pos from widget input slots', () => {
|
||||
const node = new LGraphNode('test')
|
||||
// Minimal slot for serialization test - boundingRect is calculated at runtime, not serialized
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
@@ -38,11 +45,12 @@ describe('NodeSlot', () => {
|
||||
boundingRect
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
const serialized = inputAsSerialisable(widgetInputSlot, node, 0)
|
||||
expect(serialized).not.toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves pos for non-widget input slots', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const normalSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
type: 'STRING',
|
||||
@@ -50,11 +58,12 @@ describe('NodeSlot', () => {
|
||||
link: null,
|
||||
boundingRect
|
||||
}
|
||||
const serialized = inputAsSerialisable(normalSlot)
|
||||
const serialized = inputAsSerialisable(normalSlot, node, 0)
|
||||
expect(serialized).toHaveProperty('pos')
|
||||
})
|
||||
|
||||
it('preserves only widget name during serialization', () => {
|
||||
const node = new LGraphNode('test')
|
||||
// Extra widget properties simulate real data that should be stripped during serialization
|
||||
const widgetInputSlot: INodeInputSlot = {
|
||||
name: 'test-id',
|
||||
@@ -67,7 +76,7 @@ describe('NodeSlot', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const serialized = inputAsSerialisable(widgetInputSlot)
|
||||
const serialized = inputAsSerialisable(widgetInputSlot, node, 0)
|
||||
expect(serialized.widget).toEqual({ name: 'test-widget' })
|
||||
expect(serialized.widget).not.toHaveProperty('type')
|
||||
expect(serialized.widget).not.toHaveProperty('value')
|
||||
|
||||
@@ -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. */
|
||||
|
||||
174
src/lib/litegraph/src/node/slotEcosystemPatterns.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeInputSlot } from '@/lib/litegraph/src/node/NodeInputSlot'
|
||||
|
||||
function duckInputSlot(): INodeInputSlot {
|
||||
return { name: 'in', type: 'INT', link: null, boundingRect: [0, 0, 0, 0] }
|
||||
}
|
||||
|
||||
function createSourceAndTarget() {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'INT')
|
||||
graph.add(source)
|
||||
const target = new LGraphNode('Target')
|
||||
graph.add(target)
|
||||
return { graph, source, target }
|
||||
}
|
||||
|
||||
function mockCanvasContext() {
|
||||
return {
|
||||
fillStyle: '',
|
||||
beginPath: vi.fn(),
|
||||
closePath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
rect: vi.fn(),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('ecosystem slot patterns', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
LiteGraph.alwaysRepeatWarnings = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
LiteGraph.alwaysRepeatWarnings = false
|
||||
LiteGraph.onDeprecationWarning = []
|
||||
})
|
||||
|
||||
describe('duck-typed slots wrapped by _setConcreteSlots (M2)', () => {
|
||||
it('renders a connected duck-typed input in collapsed mode', () => {
|
||||
const { source, target } = createSourceAndTarget()
|
||||
target.inputs.push(duckInputSlot())
|
||||
source.connect(0, target, 0)
|
||||
|
||||
target._setConcreteSlots()
|
||||
const ctx = mockCanvasContext()
|
||||
target.drawCollapsedSlots(ctx as unknown as CanvasRenderingContext2D)
|
||||
|
||||
expect(ctx.fill).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('upgrades duck-typed entries in node.inputs to concrete slots', () => {
|
||||
const { source, target } = createSourceAndTarget()
|
||||
target.inputs.push(duckInputSlot())
|
||||
source.connect(0, target, 0)
|
||||
|
||||
target._setConcreteSlots()
|
||||
|
||||
expect(target.inputs[0]).toBeInstanceOf(NodeInputSlot)
|
||||
expect((target.inputs[0] as NodeInputSlot).isConnected).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("input literals without a 'link' key (M3)", () => {
|
||||
it('does not report a free input as occupied because the same-index output is connected', () => {
|
||||
const { source, target } = createSourceAndTarget()
|
||||
target.addInput('in', 'INT')
|
||||
source.connect(0, target, 0)
|
||||
|
||||
const linkless: Omit<INodeInputSlot, 'link'> = {
|
||||
name: 'extra',
|
||||
type: 'INT',
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}
|
||||
source.inputs.push(linkless)
|
||||
|
||||
// source: input 0 is a free linkless literal; output 0 is connected.
|
||||
expect(source.findInputSlotFree()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not report a connected linkless input literal as free', () => {
|
||||
const { source, target } = createSourceAndTarget()
|
||||
const linkless: Omit<INodeInputSlot, 'link'> = {
|
||||
name: 'in',
|
||||
type: 'INT',
|
||||
boundingRect: [0, 0, 0, 0]
|
||||
}
|
||||
target.inputs.push(linkless)
|
||||
source.connect(0, target, 0)
|
||||
|
||||
expect(target.findInputSlotFree()).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addInput / addOutput with legacy mirror keys (M5a)', () => {
|
||||
it('accepts extra_info that still carries a link value', () => {
|
||||
const node = new LGraphNode('n')
|
||||
|
||||
expect(() => node.addInput('in', 'INT', { link: null })).not.toThrow()
|
||||
expect(node.inputs).toHaveLength(1)
|
||||
expect(node.inputs[0].name).toBe('in')
|
||||
})
|
||||
|
||||
it('accepts extra_info that still carries a links value', () => {
|
||||
const node = new LGraphNode('n')
|
||||
|
||||
expect(() =>
|
||||
node.addOutput('out', 'INT', { links: null, label: 'Out' })
|
||||
).not.toThrow()
|
||||
expect(node.outputs).toHaveLength(1)
|
||||
expect(node.outputs[0].label).toBe('Out')
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct writes to the deprecated mirrors (M5b)', () => {
|
||||
it('ignores input.link writes and fires telemetry', () => {
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
const node = new LGraphNode('n')
|
||||
const input = node.addInput('in', 'INT')
|
||||
|
||||
const legacyWriter = input as { link?: unknown }
|
||||
expect(() => {
|
||||
legacyWriter.link = 42
|
||||
}).not.toThrow()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/input\.link.*connect\(\).*disconnectInput\(\)/),
|
||||
undefined
|
||||
)
|
||||
expect(input.link).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores output.links writes and fires telemetry', () => {
|
||||
const deprecationCallback = vi.fn()
|
||||
LiteGraph.onDeprecationWarning = [deprecationCallback]
|
||||
const node = new LGraphNode('n')
|
||||
const output = node.addOutput('out', 'INT')
|
||||
|
||||
const legacyWriter = output as { links?: unknown }
|
||||
expect(() => {
|
||||
legacyWriter.links = [1]
|
||||
}).not.toThrow()
|
||||
expect(deprecationCallback).toHaveBeenCalledWith(
|
||||
expect.stringMatching(
|
||||
/output\.links.*connect\(\).*disconnectOutput\(\)/
|
||||
),
|
||||
undefined
|
||||
)
|
||||
expect(output.links).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('plain-object slot reads after concretisation (M6a)', () => {
|
||||
it('reads the store-derived link id through the upgraded slot', () => {
|
||||
const { graph, source, target } = createSourceAndTarget()
|
||||
target.inputs.push(duckInputSlot())
|
||||
const link = source.connect(0, target, 0)
|
||||
|
||||
target._setConcreteSlots()
|
||||
|
||||
expect(link).not.toBeNull()
|
||||
expect(target.inputs[0].link).toBe(link!.id)
|
||||
expect(graph.getLink(target.inputs[0].link!)).toBe(link)
|
||||
})
|
||||
})
|
||||
})
|
||||
144
src/lib/litegraph/src/node/slotLinks.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
||||
|
||||
import { createTestSubgraph } from '../subgraph/__fixtures__/subgraphHelpers'
|
||||
import {
|
||||
inputHasLink,
|
||||
inputLink,
|
||||
inputLinkId,
|
||||
outputHasLinks,
|
||||
outputLinkIds,
|
||||
outputLinks
|
||||
} from './slotLinks'
|
||||
|
||||
function createConnectedGraph(targetCount: number) {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'INT')
|
||||
graph.add(source)
|
||||
|
||||
const targets = Array.from({ length: targetCount }, (_, i) => {
|
||||
const target = new LGraphNode(`Target${i}`)
|
||||
target.addInput('in', 'INT')
|
||||
graph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
return target
|
||||
})
|
||||
|
||||
return { graph, source, targets }
|
||||
}
|
||||
|
||||
describe('slotLinks', () => {
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
it('reports presence, ids, and resolved links for an output slot', () => {
|
||||
const { graph, source } = createConnectedGraph(2)
|
||||
|
||||
expect(outputHasLinks(graph, source.id, 0)).toBe(true)
|
||||
const ids = outputLinkIds(graph, source.id, 0)
|
||||
expect(ids).toHaveLength(2)
|
||||
expect([...ids]).toEqual([...ids].sort((a, b) => a - b))
|
||||
expect(outputLinks(graph, source.id, 0).map((l) => l.id)).toEqual(ids)
|
||||
})
|
||||
|
||||
it('returns nothing for an unconnected slot', () => {
|
||||
const { graph, source } = createConnectedGraph(0)
|
||||
|
||||
expect(outputHasLinks(graph, source.id, 0)).toBe(false)
|
||||
expect(outputLinkIds(graph, source.id, 0)).toEqual([])
|
||||
expect(outputLinks(graph, source.id, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('never includes floating links', () => {
|
||||
const { graph, source, targets } = createConnectedGraph(1)
|
||||
const link = inputLink(graph, targets[0].id, 0)!
|
||||
const reroute = graph.createReroute([0, 0], link)!
|
||||
graph.remove(targets[0])
|
||||
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(reroute.floatingLinkIds.size).toBe(1)
|
||||
expect(outputHasLinks(graph, source.id, 0)).toBe(false)
|
||||
expect(outputLinks(graph, source.id, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('reports presence, id, and the resolved link for an input slot', () => {
|
||||
const { graph, targets } = createConnectedGraph(1)
|
||||
const target = targets[0]
|
||||
|
||||
expect(inputHasLink(graph, target.id, 0)).toBe(true)
|
||||
const id = inputLinkId(graph, target.id, 0)
|
||||
const link = inputLink(graph, target.id, 0)
|
||||
expect(link?.id).toBe(id)
|
||||
expect(link?.target_id).toBe(target.id)
|
||||
expect(link?.target_slot).toBe(0)
|
||||
})
|
||||
|
||||
it('returns nothing for an unconnected input slot', () => {
|
||||
const { graph, targets } = createConnectedGraph(1)
|
||||
const target = targets[0]
|
||||
target.disconnectInput(0)
|
||||
|
||||
expect(inputHasLink(graph, target.id, 0)).toBe(false)
|
||||
expect(inputLinkId(graph, target.id, 0)).toBeUndefined()
|
||||
expect(inputLink(graph, target.id, 0)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never reports a floating link on the input side', () => {
|
||||
const { graph, source, targets } = createConnectedGraph(1)
|
||||
const link = inputLink(graph, targets[0].id, 0)!
|
||||
graph.createReroute([0, 0], link)
|
||||
|
||||
graph.remove(source)
|
||||
|
||||
expect(graph.floatingLinks.size).toBe(1)
|
||||
expect(inputHasLink(graph, targets[0].id, 0)).toBe(false)
|
||||
expect(inputLink(graph, targets[0].id, 0)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves links inside a subgraph', () => {
|
||||
const subgraph = createTestSubgraph({ nodeCount: 2 })
|
||||
const [first, second] = subgraph.nodes
|
||||
const innerLink = first.connect(0, second, 0)!
|
||||
|
||||
expect(inputHasLink(subgraph, second.id, 0)).toBe(true)
|
||||
expect(inputLinkId(subgraph, second.id, 0)).toBe(innerLink.id)
|
||||
expect(inputLink(subgraph, second.id, 0)).toBe(innerLink)
|
||||
})
|
||||
|
||||
it('reads empty during the INPUT callback of a disconnect', () => {
|
||||
const { graph, targets } = createConnectedGraph(1)
|
||||
const target = targets[0]
|
||||
const seen: boolean[] = []
|
||||
target.onConnectionsChange = vi.fn(
|
||||
(type: NodeSlotType, _slot: number, connected: boolean) => {
|
||||
if (type === NodeSlotType.INPUT && !connected) {
|
||||
seen.push(inputHasLink(graph, target.id, 0))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
target.disconnectInput(0)
|
||||
|
||||
expect(seen).toEqual([false])
|
||||
})
|
||||
|
||||
it('reads empty during the final OUTPUT callback of a disconnect-all', () => {
|
||||
const { graph, source } = createConnectedGraph(2)
|
||||
const seen: boolean[] = []
|
||||
source.onConnectionsChange = vi.fn(
|
||||
(type: NodeSlotType, _slot: number, connected: boolean) => {
|
||||
if (type === NodeSlotType.OUTPUT && !connected) {
|
||||
seen.push(outputHasLinks(graph, source.id, 0))
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
source.disconnectOutput(0)
|
||||
|
||||
expect(seen).toEqual([true, false])
|
||||
})
|
||||
})
|
||||
78
src/lib/litegraph/src/node/slotLinks.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useLinkStore } from '@/stores/linkStore'
|
||||
import type { LinkId } from '@/types/linkId'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
import type { LGraph } from '../LGraph'
|
||||
import type { LLink } from '../LLink'
|
||||
|
||||
/**
|
||||
* Store-backed reads over the links attached to a node's slots.
|
||||
* These replace the `output.links[]` / `input.link` mirrors: the link
|
||||
* store is the source of truth, and floating links are never included.
|
||||
*/
|
||||
|
||||
/** True when a link targets the input slot. */
|
||||
export function inputHasLink(
|
||||
graph: Pick<LGraph, 'rootGraph'>,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): boolean {
|
||||
return useLinkStore().isInputSlotConnected(graph.rootGraph.id, nodeId, slot)
|
||||
}
|
||||
|
||||
/** Id of the link targeting an input slot, if any. */
|
||||
export function inputLinkId(
|
||||
graph: Pick<LGraph, 'rootGraph'>,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): LinkId | undefined {
|
||||
return useLinkStore().getInputSlotLink(graph.rootGraph.id, nodeId, slot)?.id
|
||||
}
|
||||
|
||||
/** The link targeting an input slot, resolved in the owning graph. */
|
||||
export function inputLink(
|
||||
graph: LGraph,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): LLink | undefined {
|
||||
const id = inputLinkId(graph, nodeId, slot)
|
||||
return id === undefined ? undefined : graph.getLink(id)
|
||||
}
|
||||
|
||||
/** True when at least one link leaves the output slot. */
|
||||
export function outputHasLinks(
|
||||
graph: Pick<LGraph, 'rootGraph'>,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): boolean {
|
||||
return useLinkStore().isOutputSlotConnected(graph.rootGraph.id, nodeId, slot)
|
||||
}
|
||||
|
||||
/** Ids of the links leaving an output slot, in ascending id order. */
|
||||
export function outputLinkIds(
|
||||
graph: Pick<LGraph, 'rootGraph'>,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): LinkId[] {
|
||||
const ids = [
|
||||
...useLinkStore().getOutputSlotLinks(graph.rootGraph.id, nodeId, slot)
|
||||
].map((topology) => topology.id)
|
||||
return ids.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of the links leaving an output slot, resolved in the owning
|
||||
* graph. Safe to disconnect links while iterating the result.
|
||||
*/
|
||||
export function outputLinks(
|
||||
graph: LGraph,
|
||||
nodeId: NodeId,
|
||||
slot: number
|
||||
): LLink[] {
|
||||
const links: LLink[] = []
|
||||
for (const id of outputLinkIds(graph, nodeId, slot)) {
|
||||
const link = graph.getLink(id)
|
||||
if (link) links.push(link)
|
||||
}
|
||||
return links
|
||||
}
|
||||
@@ -1,36 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { IWidget } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { toLinkId } from '@/types/linkId'
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import { outputAsSerialisable } from './slotUtils'
|
||||
|
||||
type OutputSlotParam = INodeOutputSlot & { widget?: IWidget }
|
||||
|
||||
function createConnectedGraph(targetCount: number) {
|
||||
const graph = new LGraph()
|
||||
const source = new LGraphNode('Source')
|
||||
source.addOutput('out', 'number')
|
||||
graph.add(source)
|
||||
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
const target = new LGraphNode(`Target${i}`)
|
||||
target.addInput('in', 'number')
|
||||
graph.add(target)
|
||||
source.connect(0, target, 0)
|
||||
}
|
||||
|
||||
return { graph, source }
|
||||
}
|
||||
|
||||
describe('outputAsSerialisable', () => {
|
||||
it('clones the links array to prevent shared reference mutation', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const output = node.addOutput('out', 'number')
|
||||
output.links = [toLinkId(1), toLinkId(2), toLinkId(3)]
|
||||
beforeEach(() => setActivePinia(createTestingPinia({ stubActions: false })))
|
||||
|
||||
const serialised = outputAsSerialisable(output as OutputSlotParam)
|
||||
it('serialises the links leaving the slot, ascending by id', () => {
|
||||
const { source } = createConnectedGraph(3)
|
||||
|
||||
expect(serialised.links).toEqual([1, 2, 3])
|
||||
expect(serialised.links).not.toBe(output.links)
|
||||
const serialised = outputAsSerialisable(
|
||||
source.outputs[0] as OutputSlotParam,
|
||||
source,
|
||||
0
|
||||
)
|
||||
|
||||
// Mutating the live array should NOT affect the serialised copy
|
||||
output.links.push(toLinkId(4))
|
||||
expect(serialised.links).toHaveLength(3)
|
||||
expect(serialised.links).toEqual([...serialised.links!].sort())
|
||||
})
|
||||
|
||||
it('preserves null links', () => {
|
||||
const node = new LGraphNode('test')
|
||||
const output = node.addOutput('out', 'number')
|
||||
output.links = null
|
||||
it('returns a snapshot unaffected by later graph changes', () => {
|
||||
const { source } = createConnectedGraph(2)
|
||||
|
||||
const serialised = outputAsSerialisable(output as OutputSlotParam)
|
||||
const serialised = outputAsSerialisable(
|
||||
source.outputs[0] as OutputSlotParam,
|
||||
source,
|
||||
0
|
||||
)
|
||||
expect(serialised.links).toHaveLength(2)
|
||||
|
||||
source.disconnectOutput(0)
|
||||
expect(serialised.links).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('serialises null when the slot has no links', () => {
|
||||
const { source } = createConnectedGraph(0)
|
||||
|
||||
const serialised = outputAsSerialisable(
|
||||
source.outputs[0] as OutputSlotParam,
|
||||
source,
|
||||
0
|
||||
)
|
||||
expect(serialised.links).toBeNull()
|
||||
})
|
||||
|
||||
it('serialises null for a node with no graph', () => {
|
||||
const node = new LGraphNode('Detached')
|
||||
node.addOutput('out', 'number')
|
||||
|
||||
const serialised = outputAsSerialisable(
|
||||
node.outputs[0] as OutputSlotParam,
|
||||
node,
|
||||
0
|
||||
)
|
||||
expect(serialised.links).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
IWidgetInputSlot,
|
||||
SharedIntersection
|
||||
@@ -5,9 +6,9 @@ import type {
|
||||
import type {
|
||||
INodeInputSlot,
|
||||
INodeOutputSlot,
|
||||
INodeSlot,
|
||||
IWidget
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import { inputLinkId, outputLinkIds } from '@/lib/litegraph/src/node/slotLinks'
|
||||
import type {
|
||||
ISerialisableNodeInput,
|
||||
ISerialisableNodeOutput
|
||||
@@ -48,12 +49,16 @@ function shallowCloneCommonProps(slot: CommonIoSlotProps): CommonIoSlotProps {
|
||||
}
|
||||
|
||||
export function inputAsSerialisable(
|
||||
slot: INodeInputSlot
|
||||
slot: INodeInputSlot,
|
||||
node: LGraphNode,
|
||||
slotIndex: number
|
||||
): ISerialisableNodeInput {
|
||||
const { link } = slot
|
||||
const widgetOrPos = slot.widget
|
||||
? { widget: { name: slot.widget.name } }
|
||||
: { pos: slot.pos }
|
||||
const link = node.graph
|
||||
? (inputLinkId(node.graph, node.id, slotIndex) ?? null)
|
||||
: null
|
||||
|
||||
return {
|
||||
...shallowCloneCommonProps(slot),
|
||||
@@ -63,25 +68,24 @@ export function inputAsSerialisable(
|
||||
}
|
||||
|
||||
export function outputAsSerialisable(
|
||||
slot: INodeOutputSlot & { widget?: IWidget }
|
||||
slot: INodeOutputSlot & { widget?: IWidget },
|
||||
node: LGraphNode,
|
||||
slotIndex: number
|
||||
): ISerialisableNodeOutput {
|
||||
const { pos, slot_index, links, widget } = slot
|
||||
const { pos, slot_index, widget } = slot
|
||||
// Output widgets do not exist in Litegraph; this is a temporary downstream workaround.
|
||||
const outputWidget = widget ? { widget: { name: widget.name } } : null
|
||||
const ids = node.graph ? outputLinkIds(node.graph, node.id, slotIndex) : []
|
||||
|
||||
return {
|
||||
...shallowCloneCommonProps(slot),
|
||||
...outputWidget,
|
||||
pos,
|
||||
slot_index,
|
||||
links: links ? [...links] : links
|
||||
links: ids.length ? ids : null
|
||||
}
|
||||
}
|
||||
|
||||
export function isINodeInputSlot(slot: INodeSlot): slot is INodeInputSlot {
|
||||
return 'link' in slot
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard: Whether this input slot is attached to a widget.
|
||||
* @param slot The slot to check.
|
||||
|
||||
75
src/lib/litegraph/src/nodeBadgeDraw.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { BadgeData } from '@/types/badgeData'
|
||||
|
||||
import { registerBadgeIcon } from './badgeIconRegistry'
|
||||
import { badgeDrawObjects } from './nodeBadgeDraw'
|
||||
|
||||
function coreRow(part: BadgeData['part'], text: string): BadgeData {
|
||||
return { kind: 'core', part, text, fgColor: '#fff', bgColor: '#000' }
|
||||
}
|
||||
|
||||
describe('badgeDrawObjects', () => {
|
||||
it('joins core parts into one badge in id, lifecycle, source order', () => {
|
||||
const badges = badgeDrawObjects({}, [
|
||||
coreRow('lifecycle', '[BETA]'),
|
||||
coreRow('id', '#5'),
|
||||
coreRow('source', 'my-pack')
|
||||
])
|
||||
|
||||
expect(badges).toHaveLength(1)
|
||||
expect(badges[0].text).toBe('#5 [BETA] my-pack')
|
||||
expect(badges[0].fgColor).toBe('#fff')
|
||||
expect(badges[0].bgColor).toBe('#000')
|
||||
})
|
||||
|
||||
it('truncates the joined core text', () => {
|
||||
const badges = badgeDrawObjects({}, [
|
||||
coreRow('source', 'a'.repeat(40)),
|
||||
coreRow('id', '#5')
|
||||
])
|
||||
|
||||
expect(badges[0].text).toHaveLength(31)
|
||||
expect(badges[0].text.endsWith('...')).toBe(true)
|
||||
})
|
||||
|
||||
it('draws credits rows separately with their registered icon', () => {
|
||||
registerBadgeIcon('test-icon', { unicode: 'X', fontFamily: 'test' })
|
||||
const badges = badgeDrawObjects({}, [
|
||||
coreRow('id', '#5'),
|
||||
{
|
||||
kind: 'credits',
|
||||
text: '$0.04',
|
||||
iconKey: 'test-icon',
|
||||
fgColor: '#fff',
|
||||
bgColor: '#8D6932'
|
||||
}
|
||||
])
|
||||
|
||||
expect(badges).toHaveLength(2)
|
||||
expect(badges[1].text).toBe('$0.04')
|
||||
expect(badges[1].bgColor).toBe('#8D6932')
|
||||
expect(badges[1].icon?.unicode).toBe('X')
|
||||
})
|
||||
|
||||
it('keeps an unknown icon key iconless', () => {
|
||||
const badges = badgeDrawObjects({}, [
|
||||
{ kind: 'credits', text: '$1', iconKey: 'nope' }
|
||||
])
|
||||
|
||||
expect(badges[0].icon).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reuses draw objects until the rows change content', () => {
|
||||
const node = {}
|
||||
const rows: BadgeData[] = [coreRow('id', '#5')]
|
||||
|
||||
const first = badgeDrawObjects(node, rows)
|
||||
const second = badgeDrawObjects(node, [coreRow('id', '#5')])
|
||||
expect(second[0]).toBe(first[0])
|
||||
|
||||
const third = badgeDrawObjects(node, [coreRow('id', '#6')])
|
||||
expect(third[0]).not.toBe(first[0])
|
||||
expect(third[0].text).toBe('#6')
|
||||
})
|
||||
})
|
||||