mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-05 12:44:23 +00:00
Compare commits
17 Commits
bl/posthog
...
drjkl/just
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87b8ff016e | ||
|
|
ee8f1b40e0 | ||
|
|
2b8b1b174c | ||
|
|
307ece903b | ||
|
|
7dca6ce05d | ||
|
|
a1b4a9c33b | ||
|
|
e2483a9c65 | ||
|
|
5208b29b5a | ||
|
|
c9e109ad61 | ||
|
|
c4ac2425af | ||
|
|
7e94798512 | ||
|
|
03e922fc13 | ||
|
|
7d867b5960 | ||
|
|
55d0010011 | ||
|
|
36fe47670a | ||
|
|
b1ffc074d9 | ||
|
|
fb502ffb6d |
@@ -77,14 +77,42 @@ test.describe('Optional input', { tag: ['@screenshot', '@node'] }, () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('simple_slider.png')
|
||||
})
|
||||
|
||||
test('unknown converted widget', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'missing_nodes_converted_widget.png'
|
||||
)
|
||||
})
|
||||
test(
|
||||
'unknown converted widget',
|
||||
{ tag: ['@vue-nodes'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_nodes_converted_widget'
|
||||
)
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
// Title reflects the unknown node type
|
||||
await expect(node).toContainText('UNKNOWN NODE')
|
||||
|
||||
// Inputs include the regular IMAGE input and the converted-widget "foo"
|
||||
// (which is what differentiates this fixture from a plain missing node).
|
||||
const nodeRef = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
const inputs = (await nodeRef.getProperty('inputs')) as {
|
||||
name: string
|
||||
type: string
|
||||
widget?: { name: string }
|
||||
}[]
|
||||
expect(inputs.map((i) => i.name)).toEqual(['image', 'foo'])
|
||||
|
||||
const fooInput = inputs.find((i) => i.name === 'foo')
|
||||
expect(fooInput?.type).toBe('STRING')
|
||||
expect(fooInput?.widget?.name).toBe('foo')
|
||||
|
||||
// Per-node DOM screenshot localizes any visual regression to this node.
|
||||
// A canvas-wide screenshot turns a 1-row label drift into a "763 px diff"
|
||||
// with no signal about which widget is responsible.
|
||||
await expect(node).toHaveScreenshot(
|
||||
'missing_nodes_converted_widget_node.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('dynamically added input', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('inputs/dynamically_added_input')
|
||||
|
||||
@@ -239,19 +239,20 @@ The design goal is to preserve ECS modularity while keeping render throughput wi
|
||||
|
||||
Companion architecture documents that expand on the design in this ADR:
|
||||
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [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.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [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 |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
| Document | Description |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
|
||||
| [Entity Interactions](../architecture/entity-interactions.md) | Maps all current entity relationships and interaction patterns — the ECS migration baseline |
|
||||
| [Entity System Structural Problems](../architecture/entity-problems.md) | Detailed problem catalog with line-level code references motivating the ECS migration |
|
||||
| [Proto-ECS Stores](../architecture/proto-ecs-stores.md) | Inventory of existing Pinia stores that already partially implement ECS patterns |
|
||||
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
|
||||
| [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.) |
|
||||
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
|
||||
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
|
||||
| [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, and Bevy — patterns adopted, departed, when to revisit |
|
||||
| [Change Tracker](../architecture/change-tracker.md) | Documents the current undo/redo system that ECS cross-cutting concerns will replace |
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ reads could collapse back to the interior source widget, while host
|
||||
flowchart TD
|
||||
workflow[Workflow JSON] --> proxyWidgets[properties.proxyWidgets]
|
||||
workflow --> hostValues[host widgets_values]
|
||||
proxyWidgets --> promotionStore[PromotionStore / promotion runtime]
|
||||
promotionStore --> sourceWidget[Interior source widget]
|
||||
proxyWidgets --> legacyRuntime[Legacy promotion runtime (removed)]
|
||||
legacyRuntime --> sourceWidget[Interior source widget]
|
||||
linkedInput[Linked SubgraphInput] --> hostWidget[Host promoted widget]
|
||||
sourceWidget --> hostWidget
|
||||
hostValues --> hostWidget
|
||||
@@ -27,7 +27,7 @@ flowchart TD
|
||||
classDef ambiguous fill:#f8d7da,stroke:#842029,color:#330000
|
||||
classDef canonical fill:#d1e7dd,stroke:#0f5132,color:#052e16
|
||||
|
||||
class proxyWidgets,promotionStore legacy
|
||||
class proxyWidgets,legacyRuntime legacy
|
||||
class sourceWidget,hostValues ambiguous
|
||||
class linkedInput,hostWidget canonical
|
||||
```
|
||||
|
||||
388
docs/architecture/appendix-ecs-pattern-survey.md
Normal file
388
docs/architecture/appendix-ecs-pattern-survey.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Appendix: ECS Pattern Survey
|
||||
|
||||
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
|
||||
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
|
||||
analysis that shipped slice 1 of
|
||||
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
|
||||
which structural patterns our `src/world/` substrate adopts, which it
|
||||
deliberately departs from, and where the trade-offs are load-bearing rather
|
||||
than incidental. Thyseus is called out specifically because it is the most
|
||||
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
|
||||
closest external analog to the command layer ADR 0003 / ADR 0008 are
|
||||
converging on, so it gets dedicated treatment in §2.5 and §3.5._
|
||||
|
||||
The in-code anchors for the load-bearing constraints discussed below are the
|
||||
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
|
||||
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
|
||||
contract) — see §3 below.
|
||||
|
||||
---
|
||||
|
||||
## 1. Survey Comparison
|
||||
|
||||
Six libraries were sampled for structural patterns: where component
|
||||
definitions live relative to the substrate, how components are declared,
|
||||
how entities are identified, and roughly how large the substrate's public
|
||||
surface is. Sources: the linked READMEs and docs.
|
||||
|
||||
| Library | Component placement | Component definition style | Entity ID type | Approx. # core exports |
|
||||
| -------------------------------------------------- | ------------------------------------ | ---------------------------------------------- | -------------------- | ---------------------------------------------------------------------: |
|
||||
| [bitECS](https://github.com/NateTheGreatt/bitECS) | Outside the substrate; user's choice | plain arrays / objects | `number` (unbranded) | ~12 |
|
||||
| [miniplex](https://github.com/hmans/miniplex) | Colocated with the `Entity` type | properties on a TS type | plain object ref | ~5 |
|
||||
| [koota](https://github.com/pmndrs/koota) | Colocated with the consumer | `trait({...})` factory | numeric `.id()` | ~15 (core) + ~8 (react) |
|
||||
| [ECSY](https://github.com/ecsyjs/ecsy) | User's choice | `class extends Component` | `Entity` object | ~10 |
|
||||
| [Thyseus](https://github.com/JaimeGensler/thyseus) | Colocated with the consumer | plain ES6 `class` (instances stored as values) | numeric (via handle) | ~25 (`World`/`Schedule`/`Query`/`Commands`/filters/`Resource`/`Event`) |
|
||||
| [Bevy](https://bevyengine.org/) (Rust, for shape) | Plugin-owned (industry std) | `#[derive(Component)] struct` | `Entity(u64)` | n/a |
|
||||
|
||||
Two structural patterns are unanimous across the surveyed libraries:
|
||||
|
||||
1. **Component definitions live with the code that owns the data**, not
|
||||
inside the substrate package. Whether by explicit recommendation
|
||||
(Bevy plugins, koota's colocation guidance, Thyseus's
|
||||
`import { Position, Velocity } from './components'` convention) or by
|
||||
default (bitECS, miniplex), no surveyed substrate ships pre-defined
|
||||
component types.
|
||||
2. **Substrate surface area is small** — bitECS at ~12 exports, koota at
|
||||
~15, miniplex at ~5. ECSY and Thyseus are the outliers: ECSY exposes a
|
||||
wider class hierarchy, and Thyseus exposes a broader Bevy-shaped
|
||||
surface (Commands, Schedules, Resources, Events, filter combinators)
|
||||
because it commits to a full system-execution runtime, not just
|
||||
storage.
|
||||
|
||||
Our slice-1 end state — five source files under
|
||||
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
|
||||
this band.
|
||||
|
||||
---
|
||||
|
||||
## 2. Patterns We Adopt
|
||||
|
||||
### 2.1 Substrate is deep; components live in domain code
|
||||
|
||||
The mainstream convention is that the ECS substrate exposes only the
|
||||
machinery — entities, component keys, a World — and component definitions
|
||||
live next to the system, store, or feature module that owns the data.
|
||||
This is the Bevy / miniplex / koota convention by design and the bitECS /
|
||||
ECSY convention by default.
|
||||
|
||||
Our substrate follows the same shape: `src/world/` contains entity-ID
|
||||
brands, the `ComponentKey` definition primitive, and the `World`
|
||||
interface, but no domain-specific component types. Slice 1 places
|
||||
`WidgetValueComponent` and `WidgetContainerComponent` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts),
|
||||
next to [widgetValueStore.ts](../../src/stores/widgetValueStore.ts) — the
|
||||
module that already owns widget value state.
|
||||
|
||||
This keeps the substrate / domain seam crisp: the World knows how to store
|
||||
and look up arbitrary components keyed by entity ID; the domain layer
|
||||
knows what a "widget value" is. It also aligns with the AGENTS.md DDD
|
||||
guidance to group code by bounded context. Future components follow the
|
||||
same rule — `PositionComponent`, when it lands, will live with the layout
|
||||
domain rather than inside the substrate.
|
||||
|
||||
### 2.2 Small public API
|
||||
|
||||
The substrate exports ~14 names — comparable to bitECS (~12) and koota
|
||||
(~15), much smaller than ECSY's class hierarchy. This is a deliberate
|
||||
target: every exported name is a contract a contributor must understand
|
||||
before extending the World, and every export is a potential migration
|
||||
cost when the substrate evolves.
|
||||
|
||||
The `Brand` / `EntityId` / `ComponentKey` / `World` / `worldInstance`
|
||||
split keeps each module single-purpose. `Brand<T,Tag>` is 5
|
||||
LOC and shared across all branded ID kinds. `ComponentKey<TData,TEntity>`
|
||||
carries a two-parameter phantom that enables cross-kind compile-time
|
||||
checking. `asGraphId` is a single named boundary cast. The two explicit
|
||||
factories `nodeEntityId` / `widgetEntityId` are kept rather than collapsed
|
||||
into a parameterized helper because slice 2/3/4 will add factories with
|
||||
different parameter tuples (`rerouteEntityId`, `linkEntityId`,
|
||||
`slotEntityId`); the explicit-factory pattern scales linearly with new
|
||||
entity kinds without growing the helper's signature.
|
||||
|
||||
### 2.3 Reactive bridging via existing storage proxy
|
||||
|
||||
bitECS, koota, and miniplex bolt on a separate `onChange` event bus when
|
||||
a consumer wants reactive notifications. koota's React layer
|
||||
(`useTrait(entity, ComponentKey)`) is the closest analog to what
|
||||
`useUpstreamValue` and future composables want.
|
||||
|
||||
Because our World stores values inside Vue's `reactive(Map<EntityId, ...>)`,
|
||||
a plain `computed(() => world.getComponent(id, key))` already provides
|
||||
fine-grained per-`(entity, component)` tracking — no separate event bus
|
||||
is needed. **This is a real Vue-specific advantage.** The Vue tracker and
|
||||
the ECS storage are the same mechanism, so reactivity falls out of the
|
||||
storage choice rather than being layered on top.
|
||||
|
||||
### 2.4 Brand-typed entity IDs
|
||||
|
||||
No surveyed TypeScript ECS uses branded IDs. bitECS uses unbranded
|
||||
`number`, miniplex uses plain object references, koota uses a numeric
|
||||
`.id()`, and Thyseus hands back a numeric handle wrapped in `Commands`
|
||||
APIs. Our `Brand<T, Tag>` over each entity kind enables the type-level
|
||||
cross-kind isolation assertion in
|
||||
[world.test.ts](../../src/world/world.test.ts) and documents slice-2/3/4
|
||||
entity kinds at compile time.
|
||||
|
||||
This is a deliberate departure rather than an accident. It earns its keep
|
||||
once `Position` lands on `NodeEntityId | RerouteEntityId` (slice 2) and
|
||||
`Connectivity` lands on `SlotEntityId` (slice 4); without brands, those
|
||||
component-key declarations would accept any numeric ID and silently allow
|
||||
cross-kind misuse.
|
||||
|
||||
### 2.5 Commands pattern (Thyseus / Bevy) — direction we are converging on
|
||||
|
||||
Thyseus mutates the World exclusively through a `Commands` system
|
||||
parameter:
|
||||
|
||||
```ts
|
||||
export function spawnEntities(commands: Commands) {
|
||||
commands.spawn().add(new Position()).add(new Velocity(1, 2))
|
||||
}
|
||||
```
|
||||
|
||||
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
|
||||
deferred mutations against a command buffer; the World applies them at
|
||||
defined sync points in the schedule. This is the same shape Bevy uses
|
||||
and is the closest direct external analog to the mutation layer
|
||||
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
|
||||
[World API and Command Layer](./ecs-world-command-api.md) describe for
|
||||
this codebase.
|
||||
|
||||
We deliberately match the **shape** of this pattern: external callers
|
||||
submit commands; only the executor calls the World's imperative
|
||||
`setComponent` / `deleteEntity`. ADR 0008 §"Relationship to ADR 0003"
|
||||
spells this out, and the parallel with Thyseus is intentional — when we
|
||||
extend slice 1 with a command executor, the public seam will look much
|
||||
more like Thyseus's `Commands` than like koota's `entity.set(...)` or
|
||||
bitECS's `addComponent(world, ...)`.
|
||||
|
||||
What we deliberately do **not** copy from Thyseus's commands surface,
|
||||
yet:
|
||||
|
||||
- **Deferred buffering with schedule sync points.** Thyseus batches
|
||||
commands and flushes them at well-defined frame phases for archetype
|
||||
efficiency. Our command executor stays synchronous in slice 1 because
|
||||
Vue reactivity wants writes to be observable in the same microtask,
|
||||
and we have no archetype churn cost to amortize.
|
||||
- **Auto-injected `Commands` parameter.** Thyseus's runtime inspects
|
||||
system signatures and injects `Commands`, `Query<...>`, `Res<...>`,
|
||||
etc. We do not have a system-runner yet (see §3.5), so commands today
|
||||
are called through a plain executor module rather than constructor
|
||||
injection.
|
||||
|
||||
The point of calling Thyseus out separately is that when ADR 0008 lands
|
||||
its command executor slice, "what does this look like in Thyseus?" is a
|
||||
load-bearing comparison point — not a curiosity. Diverging from the
|
||||
Bevy/Thyseus shape there should require an explicit justification, not
|
||||
silent drift.
|
||||
|
||||
---
|
||||
|
||||
## 3. Patterns We Explicitly Do NOT Adopt
|
||||
|
||||
Each of the following is a real industry idiom we considered and rejected
|
||||
on load-bearing grounds. None of these are pure performance trade-offs.
|
||||
|
||||
### 3.1 Replace-on-write usage idioms
|
||||
|
||||
koota's `entity.set(Position, {...})` and miniplex's `world.add(entity)`
|
||||
**replace** component values with new objects on each write. Adopting
|
||||
either would break
|
||||
[BaseWidget.\_state](../../src/lib/litegraph/src/widgets/BaseWidget.ts)
|
||||
shared reactive identity — the contract that lets DOM widget overrides,
|
||||
`useProcessedWidgets` memoization, and the 40+ extension ecosystem all
|
||||
read the same proxy. Our `setComponent(id, key, ref)` stores by reference
|
||||
and the inner `reactive(Map)` keeps a stable cached proxy per
|
||||
entity-component pair: every `getComponent` returns the same proxy,
|
||||
regardless of how many writes intervene. `widgetValueStore.registerWidget`
|
||||
returns that proxy (not the caller's input ref), so `BaseWidget._state`
|
||||
and every other reader observe the same object. Replace-on-write idioms
|
||||
would swap the cached proxy on each write and break that stability —
|
||||
the reactive-identity test in
|
||||
[widgetValueStore.test.ts](../../src/stores/widgetValueStore.test.ts)
|
||||
locks in the contract.
|
||||
|
||||
### 3.2 SoA / archetype storage
|
||||
|
||||
bitECS, koota, miniplex, and Thyseus use sparse-set / archetype storage
|
||||
internally for cache locality — Thyseus is explicitly archetypal and
|
||||
sells "lean memory use and cache-friendly iteration" as a headline
|
||||
feature. Our `reactive(Map<EntityId, unknown>)` is closer to ECSY's AoS
|
||||
— slower iteration but **integrates natively with Vue's tracking**.
|
||||
|
||||
The surface trade-off is performance; the deeper trade-off is identity.
|
||||
SoA storage spreads each component's fields across parallel typed arrays,
|
||||
so the per-entity "row object" is reconstructed on read. **A future
|
||||
migration to SoA would lose the proxy on the row object** — and with it
|
||||
the shared-reactive-identity contract that `BaseWidget._state` and the
|
||||
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
|
||||
just a perf optimization decision.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/world.ts](../../src/world/world.ts) — copied here for
|
||||
proximity:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* `setComponent` stores values by reference (no clone). The inner
|
||||
* `reactive(Map)` produces a single cached Vue proxy per entity-component
|
||||
* pair: every `getComponent` call returns the same proxy, and mutations
|
||||
* through it propagate to all readers. Note that the proxy is NOT `===`
|
||||
* to the raw object passed to `setComponent` — read through `getComponent`
|
||||
* (or a `registerWidget`-style helper that does so internally) and treat
|
||||
* that proxy as canonical.
|
||||
*
|
||||
* `BaseWidget._state` and `widgetValueStore` rely on this stable-proxy
|
||||
* invariant. Replace-on-write idioms (koota's `entity.set(...)`,
|
||||
* miniplex's `world.add(entity)`) would swap the cached proxy on each
|
||||
* write and break the contract; revisiting either consumer is required
|
||||
* before changing storage semantics.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.3 Auto-generated opaque entity IDs
|
||||
|
||||
bitECS and koota assume IDs are opaque numbers — `lastId++`, with no
|
||||
external structure. miniplex uses plain object references with the same
|
||||
property.
|
||||
|
||||
Our `widgetEntityId(rootGraphId, nodeId, name)` is **deterministic and
|
||||
content-addressed**. Consumers consistently pass `rootGraph.id`, so a
|
||||
widget viewed at different subgraph depths shares identity with itself.
|
||||
Migrating to opaque numeric IDs would break cross-subgraph value sharing —
|
||||
the same widget at depth 0 and depth 2 would receive different IDs and
|
||||
diverge.
|
||||
|
||||
The contract is pinned in the doc-comment at the top of
|
||||
[src/world/entityIds.ts](../../src/world/entityIds.ts):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, and string-prefix
|
||||
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
|
||||
* consumers consistently pass `rootGraph.id` so widgets viewed at
|
||||
* different subgraph depths share identity. Migrating to numeric IDs
|
||||
* would break cross-subgraph value sharing. See ADR 0008 and
|
||||
* widgetValueStore for the canonical keying contract.
|
||||
*/
|
||||
```
|
||||
|
||||
### 3.4 Substrate-side parent/child relations
|
||||
|
||||
Bevy ships `Parent` / `Children` components at the substrate layer; Flecs
|
||||
ships first-class relations. These are useful when many subsystems need
|
||||
hierarchical traversal at storage-near speeds.
|
||||
|
||||
We treat hierarchical traversal as a domain-layer concern instead. The
|
||||
only structural relation slice 1 needs is `node → widgets` forward
|
||||
lookup, expressed as a domain component (`WidgetContainer.widgetIds` in
|
||||
[src/stores/widgetComponents.ts](../../src/stores/widgetComponents.ts))
|
||||
and surfaced through `getNodeWidgets()` on the
|
||||
[widget value store](../../src/stores/widgetValueStore.ts). Reverse
|
||||
`widget → node` lookup is not modeled in the World at all today —
|
||||
existing call sites already hold a widget object and read `widget.node`
|
||||
directly via the `BaseWidget` back-reference, so no substrate-side
|
||||
parent component earns its keep yet. We may revisit this if multiple
|
||||
slices need a shared traversal API; until then, keeping hierarchy
|
||||
domain-local preserves the substrate's "no domain knowledge" property.
|
||||
|
||||
### 3.5 Thyseus-style system runner, schedules, and worker threads
|
||||
|
||||
Thyseus ships a full execution runtime alongside its storage:
|
||||
|
||||
- **System functions as units of work**, written as plain functions
|
||||
whose parameters (`Commands`, `Query<[Position, Velocity]>`,
|
||||
`Res<Time>`, `Maybe<Velocity>`, `With<Active>`, `Without<Frozen>`)
|
||||
describe the data they read and write.
|
||||
- **Schedules** (`class SetupSchedule extends Schedule {}`,
|
||||
`world.runSchedule(SetupSchedule)`) name groups of systems and control
|
||||
ordering / frequency, including fixed-update patterns.
|
||||
- **Boilerplate-free worker threads** for running disjoint systems in
|
||||
parallel without `eval()`.
|
||||
- **Builder `World`** assembled imperatively
|
||||
(`new World().addSystems(SetupSchedule, spawnEntities).prepare()`).
|
||||
|
||||
We deliberately do not adopt any of this in slice 1. The reasons:
|
||||
|
||||
1. **Vue already owns scheduling.** Reactivity-driven recomputation,
|
||||
`watch`, and component render passes are how work runs in this
|
||||
codebase. Inserting a parallel system scheduler would mean every
|
||||
piece of work has two possible execution contexts, and consumers
|
||||
would have to know which one applies. ADR 0008's planned executor is
|
||||
a thin command-application layer, not a fixed-step ECS schedule.
|
||||
2. **No parallelism budget to spend.** Worker-thread parallelism pays
|
||||
off when systems are CPU-bound and clearly data-disjoint. ComfyUI
|
||||
frontend's hot paths are render and DOM-bound; the cost of marshaling
|
||||
state across threads would dwarf any gain at our entity counts.
|
||||
3. **Constructor-style parameter injection has a real DX cost.**
|
||||
Thyseus's `Query<[Position, Velocity]>` injection requires the
|
||||
runtime to introspect and resolve types at registration time. That
|
||||
couples every system to the runner. The plain-function +
|
||||
`world.getComponent` shape we use today stays trivially testable
|
||||
without a `World` fixture.
|
||||
|
||||
Revisitable if (a) we end up running solver-style passes that are
|
||||
clearly CPU-bound and disjoint, or (b) the command executor grows enough
|
||||
phase ordering that an explicit schedule abstraction earns its keep over
|
||||
ad-hoc call sites. Until then, "Thyseus has a scheduler so we should
|
||||
too" is not a sufficient argument — the slice-1 substrate intentionally
|
||||
stops at storage + identity.
|
||||
|
||||
---
|
||||
|
||||
## 4. When to Revisit
|
||||
|
||||
The choices in §3 are deliberate but not eternal. Each has a revisit
|
||||
threshold.
|
||||
|
||||
**SoA / archetype storage.** The break-even point against `reactive(Map)`
|
||||
iteration is roughly **>10k entities per component** in steady-state hot
|
||||
paths. ComfyUI's projected widget count through slice 4 stays well under
|
||||
that. The watch signal is whether a render-loop or solver-loop pass
|
||||
demonstrably dominates frame time on `entitiesWith(WidgetValueComponent)`
|
||||
or any successor query — not just micro-benchmarks of `Map.get`.
|
||||
|
||||
If we cross that threshold, the migration is non-trivial: SoA loses the
|
||||
proxy on the row object (see §3.2), so a SoA World must either
|
||||
reconstruct proxies on read (defeating the perf gain) or move
|
||||
shared-identity reads back to a domain-side cache. ADR 0008's
|
||||
"Render-Loop Performance Implications and Mitigations" section already
|
||||
enumerates the planned mitigations (frame-stable query caches, archetype
|
||||
buckets, profiling-gated storage upgrades behind the World API).
|
||||
|
||||
**Replace-on-write idioms.** Revisitable only if the 40+ extension
|
||||
ecosystem moves off `BaseWidget._state` shared identity entirely — a
|
||||
separate, larger slice with explicit cost analysis (re-entry, DOM widget
|
||||
options.getValue overrides, `linkedWidgets` fan-out,
|
||||
`useProcessedWidgets` memoization invalidation), out of scope for the
|
||||
current ADR 0008 implementation.
|
||||
|
||||
**Opaque entity IDs.** Revisitable only if the cross-subgraph identity
|
||||
contract is dropped. Today widget value sharing across subgraph depths
|
||||
depends on it; slice 2 may extend the same contract to `nodeEntityId`
|
||||
for spatial reads. Until the product requirement changes, opaque IDs
|
||||
would be a regression.
|
||||
|
||||
**Substrate-side parent/child relations.** Revisitable when ≥2 subsystems
|
||||
need parent traversal. At one consumer it stays domain-local.
|
||||
|
||||
**Thyseus-style system runner / schedule / worker threads.** Revisitable
|
||||
only when the command executor grows multiple explicit phases that have
|
||||
to be ordered against each other, or when a profiled CPU-bound, clearly
|
||||
data-disjoint pass shows worker-thread parallelism would pay for the
|
||||
marshaling cost. Until both of those conditions land in a real ticket,
|
||||
keep the substrate at storage + identity and let Vue own scheduling.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-References
|
||||
|
||||
- [ADR 0008 — Entity Component System](../adr/0008-entity-component-system.md)
|
||||
for the full target taxonomy and migration strategy.
|
||||
- [ECS Target Architecture](./ecs-target-architecture.md) for the full
|
||||
end-state shape.
|
||||
- [ECS Migration Plan](./ecs-migration-plan.md) for shipping milestones.
|
||||
- [Appendix: Critical Analysis](./appendix-critical-analysis.md) for the
|
||||
independent verification of the architecture documents.
|
||||
@@ -212,7 +212,6 @@ graph LR
|
||||
B3["computedHeight, margin"]
|
||||
B4["drawWidget(), onClick()"]
|
||||
B5["useWidgetValueStore()"]
|
||||
B6["usePromotionStore()"]
|
||||
end
|
||||
|
||||
subgraph After["WidgetEntityId + Components"]
|
||||
@@ -367,7 +366,6 @@ graph TD
|
||||
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
|
||||
Canvas -->|"node.graph.remove(node)"| Graph
|
||||
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
|
||||
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
|
||||
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
|
||||
Graph -->|"useLayoutMutations()"| Store3
|
||||
LLink["LLink"] -->|"useLayoutMutations()"| Store3
|
||||
|
||||
@@ -59,12 +59,11 @@ This means the render pass is not idempotent — drawing a node changes its stat
|
||||
|
||||
### Store Dependencies in Domain Objects
|
||||
|
||||
`BaseWidget` (line 20-22) imports two Pinia stores at the module level:
|
||||
`BaseWidget` imports a Pinia store at the module level:
|
||||
|
||||
- `usePromotionStore` — queried on every `getOutlineColor()` call
|
||||
- `useWidgetValueStore` — widget state delegation via `setNodeId()`
|
||||
|
||||
Similarly, `LGraph` (lines 10-13) imports `useLayoutMutations`, `usePromotionStore`, and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
|
||||
Similarly, `LGraph` imports `useLayoutMutations` and `useWidgetValueStore`. Domain objects should not have direct dependencies on UI framework stores.
|
||||
|
||||
### Serialization Interleaved with Container Logic
|
||||
|
||||
@@ -171,7 +170,7 @@ Domain objects call Pinia composables at the module level or in methods, creatin
|
||||
|
||||
- `LLink.ts:24` — `const layoutMutations = useLayoutMutations()` (module scope)
|
||||
- `Reroute.ts` — same pattern at module scope
|
||||
- `BaseWidget.ts:20-22` — imports `usePromotionStore` and `useWidgetValueStore`
|
||||
- `BaseWidget.ts` — imports `useWidgetValueStore`
|
||||
|
||||
These make the domain objects untestable without a Vue app context.
|
||||
|
||||
@@ -192,7 +191,6 @@ The render pass is not pure — it mutates state as a side effect:
|
||||
| ----------------------------------- | ------------------------------------------------------------------- |
|
||||
| `LGraphCanvas.drawNode()` line 5562 | `node._setConcreteSlots()` — rebuilds concrete slot arrays |
|
||||
| `LGraphCanvas.drawNode()` line 5564 | `node.arrange()` — recalculates widget positions and sizes |
|
||||
| `BaseWidget.getOutlineColor()` | Queries `PromotionStore` on every frame |
|
||||
| Link rendering | Caches `_pos` center point and `_centreAngle` on the LLink instance |
|
||||
|
||||
This means:
|
||||
|
||||
@@ -303,22 +303,29 @@ must choose before Phase 3 of the migration.
|
||||
|
||||
### Current mechanism
|
||||
|
||||
The current system has three layers:
|
||||
> **Historical note:** the legacy three-layer mechanism described below
|
||||
> (PromotionStore, PromotedWidgetViewManager, PromotedWidgetView) has been
|
||||
> removed by [ADR 0009](../adr/0009-subgraph-promoted-widgets-use-linked-inputs.md).
|
||||
> Promoted value widgets are now standard linked `SubgraphInput` widgets.
|
||||
> This section is retained for archival context.
|
||||
|
||||
1. **PromotionStore** (`src/stores/promotionStore.ts`): A ref-counted Pinia
|
||||
store mapping `graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracks
|
||||
which interior widgets are promoted and provides O(1) `isPromotedByAny()`
|
||||
queries.
|
||||
The legacy system had three layers:
|
||||
|
||||
2. **PromotedWidgetViewManager**: A reconciliation layer that maintains stable
|
||||
`PromotedWidgetView` proxy widget objects, diffing against the store on each
|
||||
update — a pattern analogous to virtual DOM reconciliation.
|
||||
1. **PromotionStore** (removed; formerly `src/stores/promotionStore.ts`): A
|
||||
ref-counted Pinia store mapping
|
||||
`graphId → subgraphNodeId → PromotedWidgetSource[]`. Tracked which interior
|
||||
widgets were promoted and provided O(1) `isPromotedByAny()` queries.
|
||||
|
||||
3. **PromotedWidgetView**: A proxy widget on the SubgraphNode that mirrors the
|
||||
interior widget's type, value, and options. Reads and writes delegate to the
|
||||
original widget's entry in `WidgetValueStore`.
|
||||
2. **PromotedWidgetViewManager** (removed): A reconciliation layer that
|
||||
maintained stable `PromotedWidgetView` proxy widget objects, diffing against
|
||||
the store on each update — a pattern analogous to virtual DOM reconciliation.
|
||||
|
||||
Serialized as `properties.proxyWidgets` on the SubgraphNode.
|
||||
3. **PromotedWidgetView** (removed): A proxy widget on the SubgraphNode that
|
||||
mirrored the interior widget's type, value, and options. Reads and writes
|
||||
delegated to the original widget's entry in `WidgetValueStore`.
|
||||
|
||||
Serialized as `properties.proxyWidgets` on the SubgraphNode (now consumed only
|
||||
during legacy load repair).
|
||||
|
||||
### Candidate A: Connections-only
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useAppModeStore } from '@/stores/appModeStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
|
||||
|
||||
@@ -22,14 +22,14 @@ vi.mock('@/scripts/app', () => ({
|
||||
}))
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
|
||||
|
||||
function makeNode(id: number, widgetNames: string[]): LGraphNode {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
entityId: widgetEntityId(rootGraphId, id, name)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@ import {
|
||||
shouldExpand
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import {
|
||||
useWidgetValueStore,
|
||||
stripGraphPrefix
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
|
||||
@@ -58,8 +55,8 @@ const { t } = useI18n()
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const favoritedWidgetsStore = useFavoritedWidgetsStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const isEditing = ref(false)
|
||||
|
||||
const widgetComponent = computed(() => {
|
||||
@@ -74,10 +71,8 @@ function resolveSourceWidget(): { node: LGraphNode; widget: IBaseWidget } {
|
||||
|
||||
const simplifiedWidget = computed((): SimplifiedWidget => {
|
||||
const { node: sourceNode, widget: sourceWidget } = resolveSourceWidget()
|
||||
const graphId = node.graph?.rootGraph?.id
|
||||
const bareNodeId = stripGraphPrefix(String(sourceNode.id))
|
||||
const widgetState = graphId
|
||||
? widgetValueStore.getWidget(graphId, bareNodeId, sourceWidget.name)
|
||||
const widgetState = sourceWidget.entityId
|
||||
? widgetValueStore.getWidget(sourceWidget.entityId)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { computed, nextTick, watch } from 'vue'
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
||||
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphNode
|
||||
@@ -47,9 +47,10 @@ describe('Node Reactivity', () => {
|
||||
expect((widget as BaseWidget).node.id).toBe(node.id)
|
||||
|
||||
// Initial value should be in store after setNodeId was called
|
||||
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
||||
const testnumId = widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
|
||||
expect(store.getWidget(testnumId)?.value).toBe(2)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(testnumId)
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const onValueChange = vi.fn()
|
||||
@@ -74,7 +75,9 @@ describe('Node Reactivity', () => {
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'testnum')
|
||||
const state = store.getWidget(
|
||||
widgetEntityId(asGraphId(graph.id), node.id, 'testnum')
|
||||
)
|
||||
if (!state) throw new Error('Expected widget state to exist')
|
||||
|
||||
const widgetValue = computed(() => state.value)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, toValue } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
@@ -66,7 +66,8 @@ vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => ({
|
||||
useWidgetValueStore: () => ({
|
||||
getNodeWidgets: vi.fn(() => [])
|
||||
getNodeWidgets: vi.fn(() => []),
|
||||
getNodeWidgetsByName: vi.fn(() => new Map())
|
||||
})
|
||||
}))
|
||||
|
||||
|
||||
@@ -1,12 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
|
||||
import {
|
||||
boundsExtractor,
|
||||
singleValueExtractor,
|
||||
useUpstreamValue
|
||||
} from './useUpstreamValue'
|
||||
|
||||
function widget(name: string, value: unknown): WidgetState {
|
||||
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: () => ({
|
||||
canvas: {
|
||||
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
function widgetState(value: unknown): WidgetState {
|
||||
return {
|
||||
type: 'INPUT',
|
||||
value,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: undefined,
|
||||
disabled: undefined
|
||||
}
|
||||
}
|
||||
|
||||
function widgetMap(
|
||||
...entries: Array<[string, unknown]>
|
||||
): Map<string, WidgetState> {
|
||||
return new Map(entries.map(([name, value]) => [name, widgetState(value)]))
|
||||
}
|
||||
|
||||
const isNumber = (v: unknown): v is number => typeof v === 'number'
|
||||
@@ -15,37 +45,37 @@ describe('singleValueExtractor', () => {
|
||||
const extract = singleValueExtractor(isNumber)
|
||||
|
||||
it('matches widget by outputName', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, 'b')).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when outputName widget has invalid value', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 'not a number')]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 'not a number'])
|
||||
expect(extract(widgets, 'b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when outputName has no match', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, 'missing')).toBe(42)
|
||||
})
|
||||
|
||||
it('falls back to unique valid widget when no outputName provided', () => {
|
||||
const widgets = [widget('a', 'text'), widget('b', 42)]
|
||||
const widgets = widgetMap(['a', 'text'], ['b', 42])
|
||||
expect(extract(widgets, undefined)).toBe(42)
|
||||
})
|
||||
|
||||
it('returns undefined when multiple widgets have valid values', () => {
|
||||
const widgets = [widget('a', 1), widget('b', 2)]
|
||||
const widgets = widgetMap(['a', 1], ['b', 2])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when no widgets have valid values', () => {
|
||||
const widgets = [widget('a', 'text')]
|
||||
const widgets = widgetMap(['a', 'text'])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
expect(extract(new Map(), undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -54,23 +84,23 @@ describe('boundsExtractor', () => {
|
||||
|
||||
it('extracts a single bounds object widget', () => {
|
||||
const bounds = { x: 10, y: 20, width: 100, height: 200 }
|
||||
const widgets = [widget('crop', bounds)]
|
||||
const widgets = widgetMap(['crop', bounds])
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('matches bounds widget by outputName', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [widget('other', 'text'), widget('crop', bounds)]
|
||||
const widgets = widgetMap(['other', 'text'], ['crop', bounds])
|
||||
expect(extract(widgets, 'crop')).toEqual(bounds)
|
||||
})
|
||||
|
||||
it('assembles bounds from individual x/y/width/height widgets', () => {
|
||||
const widgets = [
|
||||
widget('x', 10),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['x', 10],
|
||||
['y', 20],
|
||||
['width', 100],
|
||||
['height', 200]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toEqual({
|
||||
x: 10,
|
||||
y: 20,
|
||||
@@ -80,39 +110,76 @@ describe('boundsExtractor', () => {
|
||||
})
|
||||
|
||||
it('returns undefined when some bound components are missing', () => {
|
||||
const widgets = [widget('x', 10), widget('y', 20), widget('width', 100)]
|
||||
const widgets = widgetMap(['x', 10], ['y', 20], ['width', 100])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when bound components have wrong types', () => {
|
||||
const widgets = [
|
||||
widget('x', '10'),
|
||||
widget('y', 20),
|
||||
widget('width', 100),
|
||||
widget('height', 200)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['x', '10'],
|
||||
['y', 20],
|
||||
['width', 100],
|
||||
['height', 200]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for empty widgets', () => {
|
||||
expect(extract([], undefined)).toBeUndefined()
|
||||
expect(extract(new Map(), undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects partial bounds objects', () => {
|
||||
const partial = { x: 10, y: 20 }
|
||||
const widgets = [widget('crop', partial)]
|
||||
const widgets = widgetMap(['crop', partial])
|
||||
expect(extract(widgets, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('prefers single bounds object over individual widgets', () => {
|
||||
const bounds = { x: 1, y: 2, width: 3, height: 4 }
|
||||
const widgets = [
|
||||
widget('crop', bounds),
|
||||
widget('x', 99),
|
||||
widget('y', 99),
|
||||
widget('width', 99),
|
||||
widget('height', 99)
|
||||
]
|
||||
const widgets = widgetMap(
|
||||
['crop', bounds],
|
||||
['x', 99],
|
||||
['y', 99],
|
||||
['width', 99],
|
||||
['height', 99]
|
||||
)
|
||||
expect(extract(widgets, undefined)).toEqual(bounds)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useUpstreamValue (store-backed read path)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
|
||||
it('reads upstream node widgets via the widget value store', () => {
|
||||
const graphId = '00000000-0000-0000-0000-000000000001' as UUID
|
||||
const widgetId = widgetEntityId(
|
||||
asGraphId(graphId),
|
||||
'upstream-1' as NodeId,
|
||||
'value'
|
||||
)
|
||||
const state = useWidgetValueStore().registerWidget(widgetId, {
|
||||
type: 'number',
|
||||
value: 7,
|
||||
options: {}
|
||||
})
|
||||
|
||||
const upstreamValue = useUpstreamValue<number>(
|
||||
() => ({ nodeId: 'upstream-1', outputName: 'value' }),
|
||||
singleValueExtractor((v): v is number => typeof v === 'number')
|
||||
)
|
||||
|
||||
expect(upstreamValue.value).toBe(7)
|
||||
state.value = 11
|
||||
expect(upstreamValue.value).toBe(11)
|
||||
})
|
||||
|
||||
it('returns undefined when no upstream linkage is provided', () => {
|
||||
const upstreamValue = useUpstreamValue(
|
||||
() => undefined,
|
||||
singleValueExtractor((v): v is number => typeof v === 'number')
|
||||
)
|
||||
expect(upstreamValue.value).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
|
||||
import { asGraphId, nodeEntityId } from '@/world/entityIds'
|
||||
|
||||
type ValueExtractor<T = unknown> = (
|
||||
widgets: WidgetState[],
|
||||
widgets: Map<string, WidgetState>,
|
||||
outputName: string | undefined
|
||||
) => T | undefined
|
||||
|
||||
@@ -23,7 +24,9 @@ export function useUpstreamValue<T>(
|
||||
if (!upstream) return undefined
|
||||
const graphId = canvasStore.canvas?.graph?.rootGraph.id
|
||||
if (!graphId) return undefined
|
||||
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
|
||||
const widgets = widgetValueStore.getNodeWidgetsByName(
|
||||
nodeEntityId(asGraphId(graphId), upstream.nodeId)
|
||||
)
|
||||
return extractValue(widgets, upstream.outputName)
|
||||
})
|
||||
}
|
||||
@@ -33,10 +36,12 @@ export function singleValueExtractor<T>(
|
||||
): ValueExtractor<T> {
|
||||
return (widgets, outputName) => {
|
||||
if (outputName) {
|
||||
const matched = widgets.find((w) => w.name === outputName)
|
||||
const matched = widgets.get(outputName)
|
||||
if (matched && isValid(matched.value)) return matched.value
|
||||
}
|
||||
const validValues = widgets.map((w) => w.value).filter(isValid)
|
||||
const validValues = [...widgets.values()]
|
||||
.map((w) => w.value)
|
||||
.filter(isValid)
|
||||
return validValues.length === 1 ? validValues[0] : undefined
|
||||
}
|
||||
}
|
||||
@@ -60,7 +65,7 @@ export function boundsExtractor(): ValueExtractor<Bounds> {
|
||||
|
||||
// Fallback: assemble from individual widgets matching BoundingBoxInputSpec field names
|
||||
const getNum = (name: string): number | undefined => {
|
||||
const w = widgets.find((w) => w.name === name)
|
||||
const w = widgets.get(name)
|
||||
return typeof w?.value === 'number' ? w.value : undefined
|
||||
}
|
||||
const x = getNum('x')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type { PreviewExposureChainContext } from './previewExposureChain'
|
||||
import { resolvePreviewExposureChain } from './previewExposureChain'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
export interface ResolvedPreviewChainStep {
|
||||
rootGraphId: UUID
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resetSubgraphFixtureState
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
|
||||
@@ -73,9 +74,12 @@ describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
subgraph.inputNode.slots[0].connect(interior.inputs[0], interior)
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(subgraph.rootGraph.id, {
|
||||
nodeId: String(interior.id),
|
||||
name: 'widget',
|
||||
const interiorWidgetId = widgetEntityId(
|
||||
asGraphId(subgraph.rootGraph.id),
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
widgetStore.registerWidget(interiorWidgetId, {
|
||||
type: 'number',
|
||||
value: 42,
|
||||
options: {},
|
||||
@@ -90,11 +94,7 @@ describe('PromotedWidgetView — host-wins semantics', () => {
|
||||
|
||||
view.value = 99
|
||||
|
||||
const interiorState = widgetStore.getWidget(
|
||||
subgraph.rootGraph.id,
|
||||
String(interior.id),
|
||||
'widget'
|
||||
)
|
||||
const interiorState = widgetStore.getWidget(interiorWidgetId)
|
||||
expect(interiorState?.value).toBe(42)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
|
||||
import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
|
||||
import type { Point } from '@/lib/litegraph/src/interfaces'
|
||||
@@ -11,11 +11,8 @@ import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
|
||||
import { t } from '@/i18n'
|
||||
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
resolveConcretePromotedWidget,
|
||||
resolvePromotedWidgetAtHost
|
||||
@@ -24,7 +21,6 @@ import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
|
||||
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
|
||||
|
||||
import { isPromotedWidgetView } from './promotedWidgetTypes'
|
||||
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
|
||||
@@ -162,7 +158,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private getHostWidgetState(): WidgetState | undefined {
|
||||
return getWidgetState(this.entityId)
|
||||
return useWidgetValueStore().getWidget(this.entityId)
|
||||
}
|
||||
|
||||
private setHostWidgetState(value: IBaseWidget['value']): void {
|
||||
@@ -207,8 +203,12 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
}
|
||||
|
||||
private registerHostWidgetState(value: IBaseWidget['value']): void {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const existing = widgetValueStore.getWidget(this.entityId)
|
||||
if (existing) return
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
ensureWidgetState(this.entityId, {
|
||||
widgetValueStore.registerWidget(this.entityId, {
|
||||
type: resolved?.widget.type ?? 'button',
|
||||
value,
|
||||
options: { ...(resolved?.widget.options ?? {}) },
|
||||
@@ -418,19 +418,11 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
if (linkedState) return linkedState
|
||||
|
||||
const resolved = this.resolveDeepest()
|
||||
if (!resolved) return undefined
|
||||
return useWidgetValueStore().getWidget(
|
||||
this.graphId,
|
||||
stripGraphPrefix(String(resolved.node.id)),
|
||||
resolved.widget.name
|
||||
)
|
||||
if (!resolved?.widget.entityId) return undefined
|
||||
return useWidgetValueStore().getWidget(resolved.widget.entityId)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgets(): Array<{
|
||||
nodeId: NodeId
|
||||
widgetName: string
|
||||
widget: IBaseWidget
|
||||
}> {
|
||||
private getLinkedInputWidgets(): IBaseWidget[] {
|
||||
const linkedInputSlot = this.subgraphNode.inputs.find((input) => {
|
||||
if (!input._subgraphSlot) return false
|
||||
if (matchPromotedInput([input], this) !== input) return false
|
||||
@@ -457,23 +449,15 @@ class PromotedWidgetView implements IPromotedWidgetView {
|
||||
const linkedInput = linkedInputSlot?._subgraphSlot
|
||||
if (!linkedInput) return []
|
||||
|
||||
return linkedInput
|
||||
.getConnectedWidgets()
|
||||
.filter(hasWidgetNode)
|
||||
.map((widget) => ({
|
||||
nodeId: stripGraphPrefix(String(widget.node.id)),
|
||||
widgetName: widget.name,
|
||||
widget
|
||||
}))
|
||||
return linkedInput.getConnectedWidgets().filter(hasWidgetNode)
|
||||
}
|
||||
|
||||
private getLinkedInputWidgetStates(): WidgetState[] {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
return this.getLinkedInputWidgets()
|
||||
.map(({ nodeId, widgetName }) =>
|
||||
widgetStore.getWidget(this.graphId, nodeId, widgetName)
|
||||
)
|
||||
.map((widget) => widget.entityId)
|
||||
.filter((id): id is WidgetEntityId => id !== undefined)
|
||||
.map((id) => widgetValueStore.getWidget(id))
|
||||
.filter((state): state is WidgetState => state !== undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
|
||||
|
||||
@@ -121,7 +121,7 @@ function getExplicitHostWidgetValue(
|
||||
if (!widget) return undefined
|
||||
if (!isPromotedWidgetView(widget)) return widget.value
|
||||
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
const value = useWidgetValueStore().getWidget(widget.entityId)?.value
|
||||
return isWidgetValue(value) ? value : undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ 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 { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -187,11 +187,9 @@ function dynamicComboWidget(
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
}
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const getState = () =>
|
||||
widget.entityId ? widgetValueStore.getWidget(widget.entityId) : undefined
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return getState()?.value ?? widgetValue
|
||||
|
||||
@@ -47,21 +47,20 @@ function onCustomComboCreated(this: LGraphNode) {
|
||||
const widget = node.addWidget('string', widgetName, '', () => {})
|
||||
if (!widget) return
|
||||
let localValue = `${widget.value ?? ''}`
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return (
|
||||
useWidgetValueStore().getWidget(app.rootGraph.id, node.id, widgetName)
|
||||
?.value ?? localValue
|
||||
)
|
||||
const state = widget.entityId
|
||||
? widgetValueStore.getWidget(widget.entityId)
|
||||
: undefined
|
||||
return state?.value ?? localValue
|
||||
},
|
||||
set(v: string) {
|
||||
localValue = v
|
||||
const state = useWidgetValueStore().getWidget(
|
||||
app.rootGraph.id,
|
||||
node.id,
|
||||
widgetName
|
||||
)
|
||||
const state = widget.entityId
|
||||
? widgetValueStore.getWidget(widget.entityId)
|
||||
: undefined
|
||||
if (state) state.value = v
|
||||
updateCombo()
|
||||
if (!node.widgets) return
|
||||
|
||||
@@ -24,6 +24,7 @@ import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import { api } from '../../scripts/api'
|
||||
import { app } from '../../scripts/app'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
function updateUIWidget(
|
||||
@@ -150,19 +151,27 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
audioUIWidget.options.getValue = () =>
|
||||
(useWidgetValueStore().getWidget(
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
audioUIWidget.options.getValue = () => {
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)?.value as string) ?? ''
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
return (widgetState?.value as string) ?? ''
|
||||
}
|
||||
audioUIWidget.options.setValue = (v) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputName
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
SubgraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
import {
|
||||
createTestSubgraph,
|
||||
createTestSubgraphData,
|
||||
@@ -296,9 +297,8 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
})
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget(graphId, {
|
||||
nodeId: '10' as NodeId,
|
||||
name: 'seed',
|
||||
const seedId = widgetEntityId(asGraphId(graphId), '10' as NodeId, 'seed')
|
||||
widgetValueStore.registerWidget(seedId, {
|
||||
type: 'number',
|
||||
value: 1,
|
||||
options: {},
|
||||
@@ -307,7 +307,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
disabled: undefined
|
||||
})
|
||||
|
||||
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
|
||||
expect(widgetValueStore.getWidget(seedId)).toEqual(
|
||||
expect.objectContaining({ value: 1 })
|
||||
)
|
||||
expect(
|
||||
@@ -316,9 +316,7 @@ describe('Graph Clearing and Callbacks', () => {
|
||||
|
||||
graph.clear()
|
||||
|
||||
expect(
|
||||
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
|
||||
).toBeUndefined()
|
||||
expect(widgetValueStore.getWidget(seedId)).toBeUndefined()
|
||||
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { isNodeBindable } from '@/lib/litegraph/src/utils/type'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
|
||||
@@ -112,7 +112,7 @@ import type { IBaseWidget, TWidgetValue } from './types/widgets'
|
||||
import { alignNodes, distributeNodes, getBoundaryNodes } from './utils/arrange'
|
||||
import { findFirstNode, getAllNestedItems } from './utils/collections'
|
||||
import { resolveConnectingLinkColor } from './utils/linkColors'
|
||||
import { createUuidv4 } from './utils/uuid'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
|
||||
|
||||
@@ -96,9 +96,11 @@ import { BaseWidget } from './widgets/BaseWidget'
|
||||
import { toConcreteWidget } from './widgets/widgetMap'
|
||||
import type { WidgetTypeMap } from './widgets/widgetMap'
|
||||
|
||||
import type { NodeId } from '@/world/entityIds'
|
||||
|
||||
// #region Types
|
||||
|
||||
export type NodeId = number | string
|
||||
export type { NodeId }
|
||||
|
||||
export type NodeProperty = string | number | boolean | object
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
RenderShape,
|
||||
TitleMode
|
||||
} from './types/globalEnums'
|
||||
import { createUuidv4 } from './utils/uuid'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
|
||||
/**
|
||||
* The Global Scope. It contains all the registered node classes.
|
||||
|
||||
@@ -144,8 +144,8 @@ export type {
|
||||
} from './types/serialisation'
|
||||
export type { IWidget } from './types/widgets'
|
||||
export { isColorable } from './utils/type'
|
||||
export { createUuidv4 } from './utils/uuid'
|
||||
export type { UUID } from './utils/uuid'
|
||||
export { createUuidv4 } from '@/utils/uuid'
|
||||
export type { UUID } from '@/utils/uuid'
|
||||
export { truncateText } from './utils/textUtils'
|
||||
export {
|
||||
evaluateInput,
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { INodeInputSlot, Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
|
||||
import { SubgraphInput } from './SubgraphInput'
|
||||
import type { SubgraphInputNode } from './SubgraphInputNode'
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import type { RerouteId } from '@/lib/litegraph/src/Reroute'
|
||||
import type { INodeOutputSlot, Point } from '@/lib/litegraph/src/interfaces'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { zeroUuid } from '@/utils/uuid'
|
||||
|
||||
import { SubgraphOutput } from './SubgraphOutput'
|
||||
import type { SubgraphOutputNode } from './SubgraphOutputNode'
|
||||
|
||||
@@ -45,7 +45,7 @@ import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuara
|
||||
import { useDomWidgetStore } from '@/stores/domWidgetStore'
|
||||
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { readWidgetValue } from '@/world/widgetValueIO'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
|
||||
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
|
||||
@@ -1088,10 +1088,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
|
||||
)
|
||||
}
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const widgetValues = this.inputs.flatMap((input) => {
|
||||
const widget = input._widget
|
||||
if (!widget || !isPromotedWidgetView(widget)) return []
|
||||
const value = readWidgetValue(widget.entityId)
|
||||
const value = widgetValueStore.getWidget(widget.entityId)?.value
|
||||
return [isWidgetValue(value) ? value : undefined]
|
||||
})
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import type {
|
||||
Serialisable,
|
||||
SubgraphIO
|
||||
} from '@/lib/litegraph/src/types/serialisation'
|
||||
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4 } from '@/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type { SubgraphInput } from './SubgraphInput'
|
||||
import type { SubgraphInputNode } from './SubgraphInputNode'
|
||||
|
||||
@@ -24,6 +24,7 @@ import { usePreviewExposureStore } from '@/stores/previewExposureStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import {
|
||||
createEventCapture,
|
||||
@@ -588,13 +589,18 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const hostNode = createTestSubgraphNode(subgraph)
|
||||
const hostWidget = hostNode.widgets[0]
|
||||
expectPromotedWidgetView(hostWidget)
|
||||
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
|
||||
nodeId: hostNode.id,
|
||||
name: hostWidget.name,
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
})
|
||||
useWidgetValueStore().registerWidget(
|
||||
widgetEntityId(
|
||||
asGraphId(hostNode.rootGraph.id),
|
||||
hostNode.id,
|
||||
hostWidget.name
|
||||
),
|
||||
{
|
||||
type: hostWidget.type,
|
||||
value: 99,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
hostNode.serialize()
|
||||
|
||||
expect(interiorWidget.value).toBe(42)
|
||||
@@ -944,8 +950,10 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
if (c.expect.storeSeedValue !== undefined) {
|
||||
expect(
|
||||
useWidgetValueStore()
|
||||
.getNodeWidgets(host.rootGraph.id, host.id)
|
||||
.find((entry) => entry.name === 'seed')?.value
|
||||
.getNodeWidgetsByName(
|
||||
nodeEntityId(asGraphId(host.rootGraph.id), host.id)
|
||||
)
|
||||
.get('seed')?.value
|
||||
).toBe(c.expect.storeSeedValue)
|
||||
}
|
||||
})
|
||||
@@ -1006,13 +1014,14 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
const host = createTestSubgraphNode(subgraph)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
for (const { node, widget } of sources) {
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: node.id,
|
||||
name: widget.name,
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetEntityId(asGraphId(host.rootGraph.id), node.id, widget.name),
|
||||
{
|
||||
type: widget.type,
|
||||
value: `${node.title} value`,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
}
|
||||
reorderSubgraphInputsByName(host, ['second', 'first'])
|
||||
expect(host.serialize().widgets_values).toBeUndefined()
|
||||
@@ -1032,13 +1041,18 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
|
||||
const host = createTestSubgraphNode(subgraph, { id: 101 })
|
||||
const widgetStore = useWidgetValueStore()
|
||||
widgetStore.registerWidget(host.rootGraph.id, {
|
||||
nodeId: interiorNode.id,
|
||||
name: interiorWidget.name,
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
})
|
||||
widgetStore.registerWidget(
|
||||
widgetEntityId(
|
||||
asGraphId(host.rootGraph.id),
|
||||
interiorNode.id,
|
||||
interiorWidget.name
|
||||
),
|
||||
{
|
||||
type: interiorWidget.type,
|
||||
value: 'source fallback',
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
const serialized = host.serialize()
|
||||
expect(serialized.widgets_values).toBeUndefined()
|
||||
|
||||
@@ -1047,7 +1061,9 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
reloaded.configure(serialized)
|
||||
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
widgetStore.getNodeWidgets(
|
||||
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
|
||||
)
|
||||
).toEqual([])
|
||||
expect(reloaded.serialize().widgets_values).toBeUndefined()
|
||||
})
|
||||
@@ -1073,14 +1089,27 @@ describe('SubgraphWidgetPromotion', () => {
|
||||
expectPromotedWidgetView(first)
|
||||
expectPromotedWidgetView(second)
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
|
||||
widgetStore.getWidget(
|
||||
widgetEntityId(
|
||||
asGraphId(reloaded.rootGraph.id),
|
||||
reloaded.id,
|
||||
first.name
|
||||
)
|
||||
)
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
|
||||
?.value
|
||||
widgetStore.getWidget(
|
||||
widgetEntityId(
|
||||
asGraphId(reloaded.rootGraph.id),
|
||||
reloaded.id,
|
||||
second.name
|
||||
)
|
||||
)?.value
|
||||
).toBe('second host value')
|
||||
expect(
|
||||
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
|
||||
widgetStore.getNodeWidgets(
|
||||
nodeEntityId(asGraphId(reloaded.rootGraph.id), reloaded.id)
|
||||
)
|
||||
).toHaveLength(1)
|
||||
expect(reloaded.serialize().widgets_values).toEqual([
|
||||
undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type {
|
||||
LGraphConfig,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Bounds } from '@/renderer/core/layout/types'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetState } from '@/world/widgets/widgetState'
|
||||
|
||||
import type {
|
||||
CanvasColour,
|
||||
@@ -394,7 +395,7 @@ export interface IBaseWidget<
|
||||
TValue = boolean | number | string | object | undefined,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> {
|
||||
> extends WidgetState<TValue, TType, TOptions> {
|
||||
[symbol: symbol]: boolean
|
||||
|
||||
linkedWidgets?: IBaseWidget[]
|
||||
@@ -402,25 +403,9 @@ export interface IBaseWidget<
|
||||
readonly entityId?: WidgetEntityId
|
||||
|
||||
name: string
|
||||
options: TOptions
|
||||
|
||||
label?: string
|
||||
/** Widget type (see {@link TWidgetType}) */
|
||||
type: TType
|
||||
value?: TValue
|
||||
|
||||
/**
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
* (`widgets_values`). Checked by {@link LGraphNode.serialize} and
|
||||
* {@link LGraphNode.configure}.
|
||||
*
|
||||
* This is distinct from {@link IWidgetOptions.serialize}, which controls
|
||||
* whether the value is included in the API prompt sent for execution.
|
||||
*
|
||||
* @default true
|
||||
* @see IWidgetOptions.serialize — API prompt inclusion
|
||||
*/
|
||||
serialize?: boolean
|
||||
|
||||
/**
|
||||
* The computed height of the widget. Used by customized node resize logic.
|
||||
@@ -444,15 +429,10 @@ export interface IBaseWidget<
|
||||
last_y?: number
|
||||
|
||||
width?: number
|
||||
/**
|
||||
* Whether the widget is disabled. Disabled widgets are rendered at half opacity.
|
||||
* See also {@link IBaseWidget.computedDisabled}.
|
||||
*/
|
||||
disabled?: boolean
|
||||
|
||||
/**
|
||||
* The disabled state used for rendering based on various conditions including
|
||||
* {@link IBaseWidget.disabled}.
|
||||
* {@link WidgetState.disabled}.
|
||||
* @readonly [Computed] This property is computed by the node.
|
||||
*/
|
||||
computedDisabled?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import { evaluateMathExpression } from '@/lib/litegraph/src/utils/mathParser'
|
||||
|
||||
|
||||
@@ -4,9 +4,23 @@ import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { INumericWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { NumberWidget } from '@/lib/litegraph/src/widgets/NumberWidget'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { asGraphId, widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
function lookup(
|
||||
store: ReturnType<typeof useWidgetValueStore>,
|
||||
graphId: string,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
) {
|
||||
return store.getWidget(
|
||||
widgetEntityId(asGraphId(graphId as UUID), nodeId, name)
|
||||
)
|
||||
}
|
||||
|
||||
function createTestWidget(
|
||||
node: LGraphNode,
|
||||
@@ -95,7 +109,7 @@ describe('BaseWidget store integration', () => {
|
||||
widget.disabled = true
|
||||
widget.advanced = true
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'writeWidget')
|
||||
const state = lookup(store, graph.id, 1, 'writeWidget')
|
||||
expect(state?.label).toBe('Updated Label')
|
||||
expect(state?.disabled).toBe(true)
|
||||
|
||||
@@ -108,9 +122,9 @@ describe('BaseWidget store integration', () => {
|
||||
widget.setNodeId(1)
|
||||
|
||||
widget.value = 99
|
||||
expect(store.getWidget(graph.id, 1, 'valueWidget')?.value).toBe(99)
|
||||
expect(lookup(store, graph.id, 1, 'valueWidget')?.value).toBe(99)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'valueWidget')!
|
||||
const state = lookup(store, graph.id, 1, 'valueWidget')!
|
||||
state.value = 55
|
||||
expect(widget.value).toBe(55)
|
||||
})
|
||||
@@ -128,10 +142,8 @@ describe('BaseWidget store integration', () => {
|
||||
})
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'autoRegWidget')
|
||||
const state = lookup(store, graph.id, 1, 'autoRegWidget')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.nodeId).toBe(1)
|
||||
expect(state?.name).toBe('autoRegWidget')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
expect(state?.label).toBe('Auto Label')
|
||||
@@ -146,7 +158,7 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'defaultsWidget' })
|
||||
widget.setNodeId(1)
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'defaultsWidget')
|
||||
const state = lookup(store, graph.id, 1, 'defaultsWidget')
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.disabled).toBe(false)
|
||||
expect(state?.label).toBeUndefined()
|
||||
@@ -159,7 +171,7 @@ describe('BaseWidget store integration', () => {
|
||||
const widget = createTestWidget(node, { name: 'valuesWidget', value: 77 })
|
||||
widget.setNodeId(1)
|
||||
|
||||
expect(store.getWidget(graph.id, 1, 'valuesWidget')?.value).toBe(77)
|
||||
expect(lookup(store, graph.id, 1, 'valuesWidget')?.value).toBe(77)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,20 +189,20 @@ describe('BaseWidget store integration', () => {
|
||||
get() {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return defaultValue
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = lookup(store, graphId, node.id, 'system_prompt')
|
||||
return (state?.value as string) ?? defaultValue
|
||||
},
|
||||
set(v: string) {
|
||||
const graphId = widget.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
const state = store.getWidget(graphId, node.id, 'system_prompt')
|
||||
const state = lookup(store, graphId, node.id, 'system_prompt')
|
||||
if (state) state.value = v
|
||||
}
|
||||
})
|
||||
|
||||
widget.setNodeId(node.id)
|
||||
|
||||
const state = store.getWidget(graph.id, node.id, 'system_prompt')
|
||||
const state = lookup(store, graph.id, node.id, 'system_prompt')
|
||||
expect(state?.value).toBe(defaultValue)
|
||||
})
|
||||
})
|
||||
@@ -211,7 +223,7 @@ describe('BaseWidget store integration', () => {
|
||||
|
||||
widget.disabled = undefined
|
||||
|
||||
const state = store.getWidget(graph.id, 1, 'testWidget')
|
||||
const state = lookup(store, graph.id, 1, 'testWidget')
|
||||
expect(state?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
export interface DrawWidgetOptions {
|
||||
/** The width of the node where this widget will be displayed. */
|
||||
@@ -85,8 +85,8 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
computedDisabled?: boolean
|
||||
tooltip?: string
|
||||
|
||||
private _state: Omit<WidgetState, 'nodeId'> &
|
||||
Partial<Pick<WidgetState, 'nodeId'>>
|
||||
private _state: WidgetState
|
||||
private _nodeId?: NodeId
|
||||
|
||||
get label(): string | undefined {
|
||||
return this._state.label
|
||||
@@ -133,10 +133,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
}
|
||||
|
||||
get entityId(): WidgetEntityId | undefined {
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
const nodeId = this._state.nodeId
|
||||
if (!graphId || nodeId === undefined) return undefined
|
||||
return widgetEntityId(graphId, nodeId, this.name)
|
||||
return deriveWidgetEntityId(
|
||||
this.node.graph?.rootGraph.id,
|
||||
this._nodeId,
|
||||
this.name
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,16 +145,16 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
* Once set, value reads/writes will be delegated to the store.
|
||||
*/
|
||||
setNodeId(nodeId: NodeId): void {
|
||||
const graphId = this.node.graph?.rootGraph.id
|
||||
if (!graphId) return
|
||||
this._nodeId = nodeId
|
||||
const widgetId = this.entityId
|
||||
if (!widgetId) return
|
||||
|
||||
this._state = useWidgetValueStore().registerWidget(graphId, {
|
||||
this._state = useWidgetValueStore().registerWidget(widgetId, {
|
||||
...this._state,
|
||||
// BaseWidget: this.value getter returns this._state.value. So value: this.value === value: this._state.value.
|
||||
// BaseDOMWidgetImpl: this.value getter returns options.getValue?.() ?? ''. Resolves the correct initial value instead of undefined.
|
||||
// I.e., calls overriden getter -> options.getValue() -> correct value (https://github.com/Comfy-Org/ComfyUI_frontend/issues/9194).
|
||||
value: this.value,
|
||||
nodeId
|
||||
value: this.value
|
||||
})
|
||||
}
|
||||
|
||||
@@ -202,7 +203,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
|
||||
Object.assign(this, safeValues)
|
||||
|
||||
this._state = {
|
||||
name: this.name,
|
||||
type: this.type as TWidgetType,
|
||||
value,
|
||||
label,
|
||||
|
||||
@@ -294,16 +294,17 @@ describe('NodeWidgets', () => {
|
||||
|
||||
const { container } = renderComponent(nodeData)
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
widgetValueStore.registerWidget('graph-test', {
|
||||
nodeId: 'test_node',
|
||||
name: 'test_widget',
|
||||
type: 'combo',
|
||||
value: 'value',
|
||||
options: { hidden: true },
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
widgetValueStore.registerWidget(
|
||||
widgetEntityId(GRAPH_ID, 'test_node', 'test_widget'),
|
||||
{
|
||||
type: 'combo',
|
||||
value: 'value',
|
||||
options: { hidden: true },
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
)
|
||||
|
||||
await nextTick()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { NodeBadgeProps } from '@/renderer/extensions/vueNodes/components/NodeBadge.vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
|
||||
@@ -40,13 +41,13 @@ export function trackNodePrice(node: TrackableNode) {
|
||||
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = getRelevantWidgetNames(node.type)
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = app.canvas?.graph?.rootGraph.id
|
||||
if (relevantNames.length > 0 && node.id != null) {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
const entityId = deriveWidgetEntityId(graphId, node.id, name)
|
||||
if (entityId) void widgetValueStore.getWidget(entityId)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
@@ -142,13 +143,13 @@ export function usePartitionedBadges(nodeData: VueNodeData) {
|
||||
if (isDynamicPricing.value) {
|
||||
// Access only the widget values that affect pricing (from widgetValueStore)
|
||||
const relevantNames = relevantPricingWidgets.value
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const graphId = app.canvas?.graph?.rootGraph.id
|
||||
if (relevantNames.length > 0 && nodeData?.id != null) {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
for (const name of relevantNames) {
|
||||
// Access value from store to create reactive dependency
|
||||
if (!graphId) continue
|
||||
void widgetStore.getWidget(graphId, nodeData.id, name)?.value
|
||||
const entityId = deriveWidgetEntityId(graphId, nodeData.id, name)
|
||||
if (entityId) void widgetValueStore.getWidget(entityId)?.value
|
||||
}
|
||||
}
|
||||
// Access input connections for regular inputs
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { deriveWidgetEntityId, widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
const GRAPH_ID = 'graph-test'
|
||||
|
||||
@@ -432,13 +432,14 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
callback
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
useWidgetValueStore().registerWidget(
|
||||
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
|
||||
{
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler(42)
|
||||
@@ -466,18 +467,22 @@ describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
nodeId: NODE_ID
|
||||
})
|
||||
|
||||
useWidgetValueStore().registerWidget(GRAPH_ID, {
|
||||
nodeId: NODE_ID,
|
||||
name: 'seed',
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
})
|
||||
useWidgetValueStore().registerWidget(
|
||||
widgetEntityId(GRAPH_ID, NODE_ID, 'seed'),
|
||||
{
|
||||
type: 'combo',
|
||||
value: 0,
|
||||
options: {}
|
||||
}
|
||||
)
|
||||
|
||||
const [processed] = processWidgets([widget])
|
||||
processed.updateHandler(99)
|
||||
|
||||
const state = useWidgetValueStore().getWidget(GRAPH_ID, NODE_ID, 'seed')
|
||||
const entityId = deriveWidgetEntityId(GRAPH_ID, NODE_ID, 'seed')
|
||||
const state = entityId
|
||||
? useWidgetValueStore().getWidget(entityId)
|
||||
: undefined
|
||||
expect(state?.value).toBe(99)
|
||||
})
|
||||
|
||||
|
||||
@@ -25,14 +25,11 @@ import {
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import { nodeTypeValidForApp } from '@/stores/appModeStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
stripGraphPrefix,
|
||||
useWidgetValueStore
|
||||
} from '@/stores/widgetValueStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { getWidgetState } from '@/world/widgetValueIO'
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LinkedUpstreamInfo,
|
||||
@@ -77,6 +74,15 @@ interface ComputeProcessedWidgetsOptions {
|
||||
ui: WidgetUiCallbacks
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips graph-scope prefix segments from a node id (e.g. `outer:inner:42`
|
||||
* → `42`) so nested node renders get stable DOM identity keys. Not for
|
||||
* widget value lookup — that routes through {@link WidgetEntityId}.
|
||||
*/
|
||||
function extractRawNodeId(scopedId: string | number): string {
|
||||
return String(scopedId).replace(/^(.*:)+/, '')
|
||||
}
|
||||
|
||||
function createWidgetUpdateHandler(
|
||||
widgetState: WidgetState | undefined,
|
||||
widget: SafeWidgetData,
|
||||
@@ -135,10 +141,10 @@ export function getWidgetIdentity(
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
? `node:${String(extractRawNodeId(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = widget.nodeId
|
||||
? `node:${String(stripGraphPrefix(widget.nodeId))}`
|
||||
? `node:${String(extractRawNodeId(widget.nodeId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: hostNodeIdRoot
|
||||
@@ -198,15 +204,19 @@ export function computeProcessedWidgets({
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const identity = getWidgetIdentity(widget, nodeId, index)
|
||||
const widgetState = widget.entityId
|
||||
? getWidgetState(widget.entityId)
|
||||
: graphId
|
||||
? widgetValueStore.getWidget(
|
||||
graphId,
|
||||
String(stripGraphPrefix(widget.nodeId ?? nodeId ?? '')),
|
||||
widget.name
|
||||
)
|
||||
let widgetState: WidgetState | undefined
|
||||
if (widget.entityId) {
|
||||
widgetState = widgetValueStore.getWidget(widget.entityId)
|
||||
} else {
|
||||
const fallbackEntityId = deriveWidgetEntityId(
|
||||
graphId,
|
||||
String(extractRawNodeId(widget.nodeId ?? nodeId ?? '')),
|
||||
widget.name
|
||||
)
|
||||
widgetState = fallbackEntityId
|
||||
? widgetValueStore.getWidget(fallbackEntityId)
|
||||
: undefined
|
||||
}
|
||||
const mergedOptions: IWidgetOptions = {
|
||||
...(widget.options ?? {}),
|
||||
...(widgetState?.options ?? {})
|
||||
@@ -254,7 +264,7 @@ export function computeProcessedWidgets({
|
||||
widgetState,
|
||||
identity: { renderKey }
|
||||
} of uniqueWidgets) {
|
||||
const bareWidgetId = String(stripGraphPrefix(widget.nodeId ?? nodeId ?? ''))
|
||||
const bareWidgetId = String(extractRawNodeId(widget.nodeId ?? nodeId ?? ''))
|
||||
|
||||
const vueComponent =
|
||||
getComponent(widget.type) ||
|
||||
@@ -318,7 +328,7 @@ export function computeProcessedWidgets({
|
||||
e,
|
||||
widget.name,
|
||||
widget.nodeId !== undefined
|
||||
? String(stripGraphPrefix(widget.nodeId))
|
||||
? String(extractRawNodeId(widget.nodeId))
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
// TODO: This widget manually syncs with widgetValueStore via getValue/setValue.
|
||||
// Consolidate with useStringWidget into shared helpers (domWidgetHelpers.ts).
|
||||
@@ -41,24 +42,35 @@ function addMarkdownWidget(
|
||||
editable: false
|
||||
})
|
||||
|
||||
const widgetStore = useWidgetValueStore()
|
||||
|
||||
const inputEl = editor.options.element as HTMLElement
|
||||
inputEl.classList.add('comfy-markdown')
|
||||
const textarea = document.createElement('textarea')
|
||||
inputEl.append(textarea)
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const widget = node.addDOMWidget(name, 'MARKDOWN', inputEl, {
|
||||
getValue(): string {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const storedValue = widgetStore.getWidget(graphId, node.id, name)?.value
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
name
|
||||
)
|
||||
const storedValue = entityId
|
||||
? widgetValueStore.getWidget(entityId)?.value
|
||||
: undefined
|
||||
return typeof storedValue === 'string' ? storedValue : textarea.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
textarea.value = v
|
||||
editor.commands.setContent(v)
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
name
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
type TextPreviewCustomProps = Omit<
|
||||
InstanceType<typeof TextPreviewWidget>['$props'],
|
||||
@@ -25,6 +26,7 @@ export function useTextPreviewWidget(
|
||||
node: LGraphNode,
|
||||
inputSpec: InputSpec
|
||||
): IBaseWidget {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const widget = new ComponentWidgetImpl<
|
||||
string | object,
|
||||
TextPreviewCustomProps
|
||||
@@ -37,19 +39,26 @@ export function useTextPreviewWidget(
|
||||
nodeId: node.id
|
||||
},
|
||||
options: {
|
||||
getValue: () =>
|
||||
useWidgetValueStore().getWidget(
|
||||
getValue: () => {
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)?.value ?? '',
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
return widgetState?.value ?? ''
|
||||
},
|
||||
setValue: (value: string | object) => {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = useWidgetValueStore().getWidget(
|
||||
graphId,
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
inputSpec.name
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
if (widgetState)
|
||||
widgetState.value =
|
||||
typeof value === 'string' ? value : String(value)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
const TRACKPAD_DETECTION_THRESHOLD = 50
|
||||
|
||||
@@ -18,7 +19,6 @@ function addMultilineWidget(
|
||||
name: string,
|
||||
opts: { defaultVal: string; placeholder?: string }
|
||||
) {
|
||||
const widgetStore = useWidgetValueStore()
|
||||
const inputEl = document.createElement('textarea')
|
||||
inputEl.className = 'comfy-multiline-input'
|
||||
inputEl.dataset.testid = 'dom-widget-textarea'
|
||||
@@ -26,17 +26,29 @@ function addMultilineWidget(
|
||||
inputEl.placeholder = opts.placeholder || name
|
||||
inputEl.spellcheck = useSettingStore().get('Comfy.TextareaWidget.Spellcheck')
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const widget = node.addDOMWidget(name, 'customtext', inputEl, {
|
||||
getValue(): string {
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
name
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
return (widgetState?.value as string) ?? inputEl.value
|
||||
},
|
||||
setValue(v: string) {
|
||||
inputEl.value = v
|
||||
const graphId = resolveNodeRootGraphId(node, app.rootGraph.id)
|
||||
const widgetState = widgetStore.getWidget(graphId, node.id, name)
|
||||
const entityId = deriveWidgetEntityId(
|
||||
resolveNodeRootGraphId(node, app.rootGraph.id),
|
||||
node.id,
|
||||
name
|
||||
)
|
||||
const widgetState = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
if (widgetState) widgetState.value = v
|
||||
}
|
||||
})
|
||||
|
||||
@@ -76,14 +76,18 @@ vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/widgetValueStore', () => {
|
||||
vi.mock('@/stores/widgetValueStore', async () => {
|
||||
const { parseWidgetEntityId } = await import('@/world/entityIds')
|
||||
const widgetMap = new Map<string, { value: unknown }>()
|
||||
const getWidget = vi.fn((_graphId: string, _nodeId: string, name: string) =>
|
||||
widgetMap.get(name)
|
||||
)
|
||||
const getWidget = vi.fn((widgetId: string) => {
|
||||
const { name } = parseWidgetEntityId(widgetId as never)
|
||||
return widgetMap.get(name)
|
||||
})
|
||||
const getNodeWidgets = vi.fn(() => [])
|
||||
return {
|
||||
useWidgetValueStore: () => ({
|
||||
getWidget,
|
||||
getNodeWidgets,
|
||||
_widgetMap: widgetMap
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import { computed, effectScope, onScopeDispose, ref, toValue, watch } from 'vue'
|
||||
import type { ComputedRef, EffectScope, MaybeRefOrGetter, Ref } from 'vue'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import { curveDataToFloatLUT } from '@/components/curve/curveUtils'
|
||||
import type { GLSLRendererConfig } from '@/renderer/glsl/useGLSLRenderer'
|
||||
@@ -121,8 +122,8 @@ function createInnerPreview(
|
||||
lastError: Ref<string | null>,
|
||||
isActiveOut: Ref<boolean>
|
||||
): () => void {
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
const { nodeToNodeLocatorId } = useWorkflowStore()
|
||||
|
||||
let renderer: ReturnType<typeof useGLSLRenderer> | null = null
|
||||
@@ -194,18 +195,22 @@ function createInnerPreview(
|
||||
if (isGLSLNode.value) {
|
||||
const nId = nodeId.value
|
||||
if (nId == null) return undefined
|
||||
return widgetValueStore.getWidget(gId, nId, 'fragment_shader')?.value as
|
||||
| string
|
||||
| undefined
|
||||
const entityId = deriveWidgetEntityId(gId, nId, 'fragment_shader')
|
||||
return entityId
|
||||
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
|
||||
: undefined
|
||||
}
|
||||
|
||||
const inner = innerGLSLNode
|
||||
if (inner) {
|
||||
return widgetValueStore.getWidget(
|
||||
const entityId = deriveWidgetEntityId(
|
||||
gId,
|
||||
inner.id as NodeId,
|
||||
'fragment_shader'
|
||||
)?.value as string | undefined
|
||||
)
|
||||
return entityId
|
||||
? (widgetValueStore.getWidget(entityId)?.value as string | undefined)
|
||||
: undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
@@ -291,23 +296,16 @@ function createInnerPreview(
|
||||
: nodeId.value
|
||||
if (sizeModeNodeId == null) return null
|
||||
|
||||
const sizeMode = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode'
|
||||
)
|
||||
const lookup = (name: string) => {
|
||||
const entityId = deriveWidgetEntityId(gId, sizeModeNodeId, name)
|
||||
return entityId ? widgetValueStore.getWidget(entityId) : undefined
|
||||
}
|
||||
|
||||
const sizeMode = lookup('size_mode')
|
||||
if (sizeMode?.value !== 'custom') return null
|
||||
|
||||
const widthWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.width'
|
||||
)
|
||||
const heightWidget = widgetValueStore.getWidget(
|
||||
gId,
|
||||
sizeModeNodeId,
|
||||
'size_mode.height'
|
||||
)
|
||||
const widthWidget = lookup('size_mode.width')
|
||||
const heightWidget = lookup('size_mode.height')
|
||||
if (!widthWidget || !heightWidget) return null
|
||||
|
||||
return clampResolution(
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { ComputedRef } from 'vue'
|
||||
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { SUBGRAPH_INPUT_ID } from '@/lib/litegraph/src/constants'
|
||||
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import {
|
||||
asGraphId,
|
||||
deriveWidgetEntityId,
|
||||
nodeEntityId
|
||||
} from '@/world/entityIds'
|
||||
|
||||
import { isCurveData } from '@/components/curve/curveUtils'
|
||||
import type { CurveData } from '@/components/curve/types'
|
||||
@@ -132,7 +137,10 @@ export function useGLSLUniforms(
|
||||
|
||||
if (subgraphSources) {
|
||||
return subgraphSources.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
|
||||
const widget = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
return coerce(widget?.value ?? directValue() ?? defaultValue)
|
||||
})
|
||||
}
|
||||
@@ -144,7 +152,8 @@ export function useGLSLUniforms(
|
||||
const values: T[] = []
|
||||
for (let i = 0; i < maxCount; i++) {
|
||||
const inputName = `${groupName}.${uniformPrefix}${i}`
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
const entityId = deriveWidgetEntityId(gId, nId, inputName)
|
||||
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
|
||||
if (widget !== undefined) {
|
||||
values.push(coerce(widget.value))
|
||||
continue
|
||||
@@ -158,8 +167,7 @@ export function useGLSLUniforms(
|
||||
const upstreamNode = node.getInputNode(slot)
|
||||
if (!upstreamNode) break
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
|
||||
)
|
||||
if (
|
||||
upstreamWidgets.length === 0 ||
|
||||
@@ -214,7 +222,10 @@ export function useGLSLUniforms(
|
||||
if (sources && sources.length > 0) {
|
||||
return sources
|
||||
.map(({ nodeId: nId, widgetName, directValue }) => {
|
||||
const widget = widgetValueStore.getWidget(gId, nId, widgetName)
|
||||
const entityId = deriveWidgetEntityId(gId, nId, widgetName)
|
||||
const widget = entityId
|
||||
? widgetValueStore.getWidget(entityId)
|
||||
: undefined
|
||||
const value = widget?.value ?? directValue()
|
||||
return isCurveData(value) ? (value as CurveData) : null
|
||||
})
|
||||
@@ -230,7 +241,8 @@ export function useGLSLUniforms(
|
||||
for (let i = 0; i < max; i++) {
|
||||
const inputName = `curves.u_curve${i}`
|
||||
|
||||
const widget = widgetValueStore.getWidget(gId, nId, inputName)
|
||||
const entityId = deriveWidgetEntityId(gId, nId, inputName)
|
||||
const widget = entityId ? widgetValueStore.getWidget(entityId) : undefined
|
||||
if (widget && isCurveData(widget.value)) {
|
||||
values.push(widget.value as CurveData)
|
||||
continue
|
||||
@@ -243,8 +255,7 @@ export function useGLSLUniforms(
|
||||
if (!upstreamNode) break
|
||||
|
||||
const upstreamWidgets = widgetValueStore.getNodeWidgets(
|
||||
gId,
|
||||
upstreamNode.id as NodeId
|
||||
nodeEntityId(asGraphId(gId), upstreamNode.id as NodeId)
|
||||
)
|
||||
const curveWidget = upstreamWidgets.find((w) => isCurveData(w.value))
|
||||
if (!curveWidget) break
|
||||
|
||||
@@ -18,6 +18,7 @@ import { app } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
|
||||
const mockEmptyWorkflowDialog = vi.hoisted(() => {
|
||||
let lastOptions: { onEnterBuilder: () => void; onDismiss: () => void }
|
||||
@@ -127,16 +128,16 @@ function createWorkflowWithLinearData(
|
||||
}
|
||||
|
||||
const rootGraphId = '11111111-1111-4111-8111-111111111111'
|
||||
const entityPrompt = `${rootGraphId}:1:prompt` as WidgetEntityId
|
||||
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
|
||||
const entitySteps = `${rootGraphId}:1:steps` as WidgetEntityId
|
||||
const entityPrompt = widgetEntityId(rootGraphId, 1, 'prompt')
|
||||
const entitySeed = widgetEntityId(rootGraphId, 1, 'seed')
|
||||
const entitySteps = widgetEntityId(rootGraphId, 1, 'steps')
|
||||
|
||||
function nodeWithWidgets(id: number, widgetNames: string[]) {
|
||||
return fromAny<LGraphNode, unknown>({
|
||||
id,
|
||||
widgets: widgetNames.map((name) => ({
|
||||
name,
|
||||
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
|
||||
entityId: widgetEntityId(rootGraphId, id, name)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import { usePreviewExposureStore } from './previewExposureStore'
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { resolvePreviewExposureChain } from '@/core/graph/subgraph/preview/previ
|
||||
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])
|
||||
|
||||
|
||||
@@ -2,25 +2,33 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
import type { WidgetState } from './widgetValueStore'
|
||||
import { useWidgetValueStore } from './widgetValueStore'
|
||||
|
||||
function widget<T>(
|
||||
nodeId: string,
|
||||
name: string,
|
||||
function widgetState<T>(
|
||||
type: string,
|
||||
value: T,
|
||||
extra: Partial<
|
||||
Omit<WidgetState<T>, 'nodeId' | 'name' | 'type' | 'value'>
|
||||
> = {}
|
||||
extra: Partial<Omit<WidgetState<T>, 'type' | 'value'>> = {}
|
||||
): WidgetState<T> {
|
||||
return { nodeId, name, type, value, options: {}, ...extra }
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
options: {},
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
describe('useWidgetValueStore', () => {
|
||||
const graphA = 'graph-a' as UUID
|
||||
const graphB = 'graph-b' as UUID
|
||||
const wid = (graphId: UUID, nodeId: NodeId, name: string): WidgetEntityId =>
|
||||
widgetEntityId(asGraphId(graphId), nodeId, name)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
})
|
||||
@@ -28,35 +36,45 @@ describe('useWidgetValueStore', () => {
|
||||
describe('widgetState.value access', () => {
|
||||
it('getWidget returns undefined for unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(wid(graphA, 'missing' as NodeId, 'widget'))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('widgetState.value can be read and written directly', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
const state = store.registerWidget(id, widgetState('number', 100))
|
||||
expect(state.value).toBe(100)
|
||||
|
||||
state.value = 200
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(200)
|
||||
expect(store.getWidget(id)?.value).toBe(200)
|
||||
})
|
||||
|
||||
it('stores different value types', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'text', 'string', 'hello'))
|
||||
store.registerWidget(graphA, widget('node-1', 'number', 'number', 42))
|
||||
store.registerWidget(graphA, widget('node-1', 'boolean', 'toggle', true))
|
||||
const node = 'node-1' as NodeId
|
||||
store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'array', 'combo', [1, 2, 3])
|
||||
wid(graphA, node, 'text'),
|
||||
widgetState('string', 'hello')
|
||||
)
|
||||
store.registerWidget(
|
||||
wid(graphA, node, 'number'),
|
||||
widgetState('number', 42)
|
||||
)
|
||||
store.registerWidget(
|
||||
wid(graphA, node, 'boolean'),
|
||||
widgetState('toggle', true)
|
||||
)
|
||||
store.registerWidget(
|
||||
wid(graphA, node, 'array'),
|
||||
widgetState('combo', [1, 2, 3])
|
||||
)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'text')?.value).toBe('hello')
|
||||
expect(store.getWidget(graphA, 'node-1', 'number')?.value).toBe(42)
|
||||
expect(store.getWidget(graphA, 'node-1', 'boolean')?.value).toBe(true)
|
||||
expect(store.getWidget(graphA, 'node-1', 'array')?.value).toEqual([
|
||||
expect(store.getWidget(wid(graphA, node, 'text'))?.value).toBe('hello')
|
||||
expect(store.getWidget(wid(graphA, node, 'number'))?.value).toBe(42)
|
||||
expect(store.getWidget(wid(graphA, node, 'boolean'))?.value).toBe(true)
|
||||
expect(store.getWidget(wid(graphA, node, 'array'))?.value).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
})
|
||||
@@ -66,15 +84,13 @@ describe('useWidgetValueStore', () => {
|
||||
it('registers a widget with minimal properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 12345)
|
||||
wid(graphA, 'node-1' as NodeId, 'seed'),
|
||||
widgetState('number', 12345)
|
||||
)
|
||||
|
||||
expect(state.nodeId).toBe('node-1')
|
||||
expect(state.name).toBe('seed')
|
||||
expect(state.type).toBe('number')
|
||||
expect(state.value).toBe(12345)
|
||||
expect(state.disabled).toBeUndefined()
|
||||
expect(state.disabled).toBe(false)
|
||||
expect(state.serialize).toBeUndefined()
|
||||
expect(state.options).toEqual({})
|
||||
})
|
||||
@@ -82,8 +98,8 @@ describe('useWidgetValueStore', () => {
|
||||
it('registers a widget with all properties', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'prompt', 'string', 'test', {
|
||||
wid(graphA, 'node-1' as NodeId, 'prompt'),
|
||||
widgetState('string', 'test', {
|
||||
label: 'Prompt Text',
|
||||
disabled: true,
|
||||
serialize: false,
|
||||
@@ -96,84 +112,187 @@ describe('useWidgetValueStore', () => {
|
||||
expect(state.serialize).toBe(false)
|
||||
expect(state.options).toEqual({ multiline: true })
|
||||
})
|
||||
|
||||
it('overwrites existing widget state when registerWidget is called twice', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
const first = store.registerWidget(id, widgetState('number', 11))
|
||||
first.value = 99
|
||||
|
||||
store.registerWidget(id, widgetState('number', 11))
|
||||
expect(store.getWidget(id)?.value).toBe(11)
|
||||
})
|
||||
|
||||
it('register-if-absent pattern preserves existing state', () => {
|
||||
// Captures the idempotency guarantee that the prior IO helper used to
|
||||
// provide: callers that want non-destructive init must check getWidget
|
||||
// first.
|
||||
const store = useWidgetValueStore()
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
store.registerWidget(id, widgetState('number', 11))
|
||||
const first = store.getWidget(id)!
|
||||
first.value = 99
|
||||
|
||||
const existing = store.getWidget(id)
|
||||
if (!existing) store.registerWidget(id, widgetState('number', 11))
|
||||
|
||||
expect(store.getWidget(id)?.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('widget getters', () => {
|
||||
it('getWidget returns widget state', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 100))
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
store.registerWidget(id, widgetState('number', 100))
|
||||
|
||||
const state = store.getWidget(graphA, 'node-1', 'seed')
|
||||
const state = store.getWidget(id)
|
||||
expect(state).toBeDefined()
|
||||
expect(state?.name).toBe('seed')
|
||||
expect(state?.type).toBe('number')
|
||||
expect(state?.value).toBe(100)
|
||||
})
|
||||
|
||||
it('getWidget returns undefined for missing widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
expect(store.getWidget(graphA, 'missing', 'widget')).toBeUndefined()
|
||||
expect(
|
||||
store.getWidget(wid(graphA, 'missing' as NodeId, 'widget'))
|
||||
).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getNodeWidgets returns all widgets for a node', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphA, widget('node-1', 'steps', 'number', 20))
|
||||
store.registerWidget(graphA, widget('node-2', 'cfg', 'number', 7))
|
||||
const node1 = 'node-1' as NodeId
|
||||
const node2 = 'node-2' as NodeId
|
||||
store.registerWidget(wid(graphA, node1, 'seed'), widgetState('number', 1))
|
||||
store.registerWidget(
|
||||
wid(graphA, node1, 'steps'),
|
||||
widgetState('number', 20)
|
||||
)
|
||||
store.registerWidget(wid(graphA, node2, 'cfg'), widgetState('number', 7))
|
||||
|
||||
const widgets = store.getNodeWidgets(graphA, 'node-1')
|
||||
const widgets = store.getNodeWidgets(
|
||||
nodeEntityId(asGraphId(graphA), node1)
|
||||
)
|
||||
expect(widgets).toHaveLength(2)
|
||||
expect(widgets.map((w) => w.name).sort()).toEqual(['seed', 'steps'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('direct property mutation', () => {
|
||||
it('disabled can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
const state = store.registerWidget(id, widgetState('number', 100))
|
||||
|
||||
state.disabled = true
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.disabled).toBe(true)
|
||||
expect(store.getWidget(id)?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('label can be set directly via getWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const state = store.registerWidget(
|
||||
graphA,
|
||||
widget('node-1', 'seed', 'number', 100)
|
||||
)
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
const state = store.registerWidget(id, widgetState('number', 100))
|
||||
|
||||
state.label = 'Random Seed'
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBe(
|
||||
'Random Seed'
|
||||
)
|
||||
expect(store.getWidget(id)?.label).toBe('Random Seed')
|
||||
|
||||
state.label = undefined
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.label).toBeUndefined()
|
||||
expect(store.getWidget(id)?.label).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('graph isolation', () => {
|
||||
it('isolates widget states by graph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
const node = 'node-1' as NodeId
|
||||
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
|
||||
store.registerWidget(wid(graphB, node, 'seed'), widgetState('number', 2))
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')?.value).toBe(1)
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(wid(graphA, node, 'seed'))?.value).toBe(1)
|
||||
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
|
||||
})
|
||||
|
||||
it('clearGraph only removes one graph namespace', () => {
|
||||
const store = useWidgetValueStore()
|
||||
store.registerWidget(graphA, widget('node-1', 'seed', 'number', 1))
|
||||
store.registerWidget(graphB, widget('node-1', 'seed', 'number', 2))
|
||||
const node = 'node-1' as NodeId
|
||||
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
|
||||
store.registerWidget(wid(graphB, node, 'seed'), widgetState('number', 2))
|
||||
|
||||
store.clearGraph(graphA)
|
||||
|
||||
expect(store.getWidget(graphA, 'node-1', 'seed')).toBeUndefined()
|
||||
expect(store.getWidget(graphB, 'node-1', 'seed')?.value).toBe(2)
|
||||
expect(store.getWidget(wid(graphA, node, 'seed'))).toBeUndefined()
|
||||
expect(store.getWidget(wid(graphB, node, 'seed'))?.value).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('returned state identity', () => {
|
||||
const node = 'node-1' as NodeId
|
||||
const sample = widgetState('number', 100)
|
||||
const widgetId = wid(graphA, node, 'seed')
|
||||
|
||||
it('getWidget returns the same reference as registerWidget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const registered = store.registerWidget(widgetId, sample)
|
||||
expect(store.getWidget(widgetId)).toBe(registered)
|
||||
})
|
||||
|
||||
it('cached references detach safely after clearGraph', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const view = store.registerWidget(widgetId, sample)
|
||||
store.clearGraph(graphA)
|
||||
view.value = 999
|
||||
view.label = 'ignored'
|
||||
view.disabled = true
|
||||
expect(store.getWidget(widgetId)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeWidgetsByName', () => {
|
||||
it('returns empty map when node has no widgets', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const map = store.getNodeWidgetsByName(
|
||||
nodeEntityId(asGraphId(graphA), 'no-such' as NodeId)
|
||||
)
|
||||
expect(map.size).toBe(0)
|
||||
})
|
||||
|
||||
it('returns map keyed by widget name', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const node = 'node-1' as NodeId
|
||||
store.registerWidget(wid(graphA, node, 'seed'), widgetState('number', 1))
|
||||
store.registerWidget(wid(graphA, node, 'cfg'), widgetState('number', 7))
|
||||
const map = store.getNodeWidgetsByName(
|
||||
nodeEntityId(asGraphId(graphA), node)
|
||||
)
|
||||
expect(map.size).toBe(2)
|
||||
expect(map.get('seed')?.value).toBe(1)
|
||||
expect(map.get('cfg')?.value).toBe(7)
|
||||
expect(map.get('missing')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setValue', () => {
|
||||
it('updates an existing widget value and returns true', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
store.registerWidget(id, widgetState('number', 1))
|
||||
expect(store.setValue(id, 99)).toBe(true)
|
||||
expect(store.getWidget(id)?.value).toBe(99)
|
||||
})
|
||||
|
||||
it('returns false when setting value on an unregistered widget', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
expect(store.setValue(id, 99)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reactivity through the view', () => {
|
||||
it('clearGraph removes data; subsequent getWidget returns undefined', () => {
|
||||
const store = useWidgetValueStore()
|
||||
const id = wid(graphA, 'node-1' as NodeId, 'seed')
|
||||
store.registerWidget(id, widgetState('number', 100))
|
||||
store.clearGraph(graphA)
|
||||
expect(store.getWidget(id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import type {
|
||||
IBaseWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
import {
|
||||
asGraphId,
|
||||
isNodeIdForGraph,
|
||||
isWidgetIdForGraph,
|
||||
nodeEntityId,
|
||||
parseWidgetEntityId
|
||||
} from '@/world/entityIds'
|
||||
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
|
||||
import type { WidgetState } from '@/world/widgets/widgetState'
|
||||
|
||||
type WidgetKey = `${NodeId}:${string}`
|
||||
|
||||
export function stripGraphPrefix(scopedId: NodeId | string): NodeId {
|
||||
return String(scopedId).replace(/^(.*:)+/, '') as NodeId
|
||||
}
|
||||
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends IWidgetOptions = IWidgetOptions
|
||||
> extends Pick<
|
||||
IBaseWidget<TValue, TType, TOptions>,
|
||||
'name' | 'type' | 'value' | 'options' | 'label' | 'serialize' | 'disabled'
|
||||
> {
|
||||
nodeId: NodeId
|
||||
}
|
||||
export type { WidgetState } from '@/world/widgets/widgetState'
|
||||
|
||||
export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
const graphWidgetStates = ref(new Map<UUID, Map<WidgetKey, WidgetState>>())
|
||||
const widgets = reactive(new Map<WidgetEntityId, WidgetState>())
|
||||
const widgetIdsByNode = reactive(new Map<NodeEntityId, WidgetEntityId[]>())
|
||||
|
||||
function getWidgetStateMap(graphId: UUID): Map<WidgetKey, WidgetState> {
|
||||
const widgetStates = graphWidgetStates.value.get(graphId)
|
||||
if (widgetStates) return widgetStates
|
||||
|
||||
const nextWidgetStates = reactive(new Map<WidgetKey, WidgetState>())
|
||||
graphWidgetStates.value.set(graphId, nextWidgetStates)
|
||||
return nextWidgetStates
|
||||
}
|
||||
|
||||
function makeKey(nodeId: NodeId, widgetName: string): WidgetKey {
|
||||
return `${nodeId}:${widgetName}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `ensureWidgetState(widget.entityId, init)` from
|
||||
* `src/world/widgetValueIO.ts` — the branded `WidgetEntityId` prevents
|
||||
* producer/consumer drift that loose triples allow.
|
||||
*/
|
||||
function registerWidget<TValue = unknown>(
|
||||
graphId: UUID,
|
||||
widgetId: WidgetEntityId,
|
||||
state: WidgetState<TValue>
|
||||
): WidgetState<TValue> {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const key = makeKey(state.nodeId, state.name)
|
||||
widgetStates.set(key, state)
|
||||
return widgetStates.get(key) as WidgetState<TValue>
|
||||
const { graphId, nodeId } = parseWidgetEntityId(widgetId)
|
||||
|
||||
widgets.set(widgetId, {
|
||||
...state,
|
||||
disabled: state.disabled ?? false
|
||||
})
|
||||
|
||||
const ownerId = nodeEntityId(graphId, nodeId)
|
||||
const ids = widgetIdsByNode.get(ownerId)
|
||||
if (!ids) {
|
||||
widgetIdsByNode.set(ownerId, [widgetId])
|
||||
} else if (!ids.includes(widgetId)) {
|
||||
ids.push(widgetId)
|
||||
}
|
||||
|
||||
return widgets.get(widgetId) as WidgetState<TValue>
|
||||
}
|
||||
|
||||
function getNodeWidgets(graphId: UUID, nodeId: NodeId): WidgetState[] {
|
||||
const widgetStates = getWidgetStateMap(graphId)
|
||||
const prefix = `${nodeId}:`
|
||||
return [...widgetStates]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, state]) => state)
|
||||
function getWidget(widgetId: WidgetEntityId): WidgetState | undefined {
|
||||
return widgets.get(widgetId)
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `getWidgetState(widget.entityId)` or
|
||||
* `readWidgetValue(widget.entityId)` from `src/world/widgetValueIO.ts` —
|
||||
* the branded `WidgetEntityId` prevents producer/consumer drift that loose
|
||||
* triples allow.
|
||||
*/
|
||||
function getWidget(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string
|
||||
): WidgetState | undefined {
|
||||
return getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
function getNodeWidgets(nodeId: NodeEntityId): WidgetState[] {
|
||||
const ids = widgetIdsByNode.get(nodeId)
|
||||
if (!ids) return []
|
||||
const result: WidgetState[] = []
|
||||
for (const widgetId of ids) {
|
||||
const w = widgets.get(widgetId)
|
||||
if (w) result.push(w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getNodeWidgetsByName(
|
||||
nodeId: NodeEntityId
|
||||
): Map<string, WidgetState> {
|
||||
const result = new Map<string, WidgetState>()
|
||||
const ids = widgetIdsByNode.get(nodeId)
|
||||
if (!ids) return result
|
||||
for (const widgetId of ids) {
|
||||
const w = widgets.get(widgetId)
|
||||
if (!w) continue
|
||||
const { name } = parseWidgetEntityId(widgetId)
|
||||
result.set(name, w)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function setValue(
|
||||
graphId: UUID,
|
||||
nodeId: NodeId,
|
||||
widgetName: string,
|
||||
widgetId: WidgetEntityId,
|
||||
value: WidgetState['value']
|
||||
): boolean {
|
||||
const state = getWidgetStateMap(graphId).get(makeKey(nodeId, widgetName))
|
||||
if (!state) return false
|
||||
state.value = value
|
||||
const widget = widgets.get(widgetId)
|
||||
if (!widget) return false
|
||||
widget.value = value
|
||||
return true
|
||||
}
|
||||
|
||||
function clearGraph(graphId: UUID): void {
|
||||
graphWidgetStates.value.delete(graphId)
|
||||
const branded = asGraphId(graphId)
|
||||
for (const widgetId of widgets.keys()) {
|
||||
if (isWidgetIdForGraph(branded, widgetId)) {
|
||||
widgets.delete(widgetId)
|
||||
}
|
||||
}
|
||||
for (const nodeId of widgetIdsByNode.keys()) {
|
||||
if (isNodeIdForGraph(branded, nodeId)) {
|
||||
widgetIdsByNode.delete(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -99,6 +99,7 @@ export const useWidgetValueStore = defineStore('widgetValue', () => {
|
||||
getWidget,
|
||||
setValue,
|
||||
getNodeWidgets,
|
||||
getNodeWidgetsByName,
|
||||
clearGraph
|
||||
}
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ import { app } from '@/scripts/app'
|
||||
import { t } from '@/i18n'
|
||||
import { parseNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import type { WidgetEntityId } from '@/world/entityIds'
|
||||
import { widgetEntityId } from '@/world/entityIds'
|
||||
import { deriveWidgetEntityId } from '@/world/entityIds'
|
||||
|
||||
type ImageNode = LGraphNode & { imgs: HTMLImageElement[] | undefined }
|
||||
type VideoNode = LGraphNode & {
|
||||
@@ -377,9 +377,7 @@ export function getWidgetEntityIdForNode(
|
||||
widget: Pick<IBaseWidget, 'name' | 'entityId'>
|
||||
): WidgetEntityId | undefined {
|
||||
if (widget.entityId) return widget.entityId
|
||||
const graphId = node.graph?.rootGraph.id
|
||||
if (!graphId || node.id === -1) return undefined
|
||||
return widgetEntityId(graphId, node.id, widget.name)
|
||||
return deriveWidgetEntityId(node.graph?.rootGraph.id, node.id, widget.name)
|
||||
}
|
||||
|
||||
export function isLoad3dNode(node: LGraphNode) {
|
||||
|
||||
@@ -1,45 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, expectTypeOf, it } from 'vitest'
|
||||
|
||||
import type { WidgetEntityId } from './entityIds'
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type { NodeEntityId, NodeId, WidgetEntityId } from './entityIds'
|
||||
import {
|
||||
asGraphId,
|
||||
deriveWidgetEntityId,
|
||||
isWidgetEntityId,
|
||||
nodeEntityId,
|
||||
parseWidgetEntityId,
|
||||
widgetEntityId
|
||||
} from './entityIds'
|
||||
|
||||
describe('widgetEntityId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
it('builds a deterministic id from its components', () => {
|
||||
const id = widgetEntityId(graphId, 42, 'seed')
|
||||
expect(id).toBe(`${graphId}:42:seed`)
|
||||
})
|
||||
|
||||
it('produces equal ids for equal inputs', () => {
|
||||
expect(widgetEntityId(graphId, 42, 'seed')).toBe(
|
||||
widgetEntityId(graphId, 42, 'seed')
|
||||
)
|
||||
})
|
||||
|
||||
it('produces distinct ids when any component differs', () => {
|
||||
const baseline = widgetEntityId(graphId, 42, 'seed')
|
||||
expect(widgetEntityId(graphId, 43, 'seed')).not.toBe(baseline)
|
||||
expect(widgetEntityId(graphId, 42, 'steps')).not.toBe(baseline)
|
||||
const otherGraph = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
expect(widgetEntityId(otherGraph, 42, 'seed')).not.toBe(baseline)
|
||||
})
|
||||
|
||||
it('accepts string node ids', () => {
|
||||
const id = widgetEntityId(graphId, 'node-7', 'value')
|
||||
expect(id).toBe(`${graphId}:node-7:value`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWidgetEntityId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const graphId = asGraphId('a3f2c1d8-4567-89ab-cdef-1234567890ab' as UUID)
|
||||
|
||||
it('round-trips a constructed id', () => {
|
||||
const id = widgetEntityId(graphId, 42, 'seed')
|
||||
it('round-trips a simple name', () => {
|
||||
const id = widgetEntityId(graphId, 42 as NodeId, 'seed')
|
||||
expect(parseWidgetEntityId(id)).toEqual({
|
||||
graphId,
|
||||
nodeId: '42',
|
||||
@@ -47,29 +24,64 @@ describe('parseWidgetEntityId', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves colons inside the name segment', () => {
|
||||
const rawName = 'nested:label:with:colons'
|
||||
const rawId = `${graphId}:42:${rawName}` as WidgetEntityId
|
||||
expect(parseWidgetEntityId(rawId)).toEqual({
|
||||
it('preserves names containing colons', () => {
|
||||
const id = widgetEntityId(graphId, 7 as NodeId, 'images.image:0')
|
||||
expect(parseWidgetEntityId(id).name).toBe('images.image:0')
|
||||
})
|
||||
|
||||
it('handles string node ids', () => {
|
||||
const id = widgetEntityId(graphId, '12:5' as NodeId, 'sub_widget')
|
||||
const parsed = parseWidgetEntityId(id)
|
||||
expect(parsed.graphId).toBe(graphId)
|
||||
})
|
||||
|
||||
it('round-trips an empty name', () => {
|
||||
const id = widgetEntityId(graphId, 1 as NodeId, '')
|
||||
expect(parseWidgetEntityId(id)).toEqual({
|
||||
graphId,
|
||||
nodeId: '42',
|
||||
name: rawName
|
||||
nodeId: '1',
|
||||
name: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('throws on missing widget: prefix', () => {
|
||||
expect(() =>
|
||||
parseWidgetEntityId(`node:${graphId}:42` as unknown as WidgetEntityId)
|
||||
).toThrow(/Malformed WidgetEntityId/)
|
||||
})
|
||||
|
||||
it('throws on too few colons', () => {
|
||||
expect(() => parseWidgetEntityId('widget:abc' as WidgetEntityId)).toThrow(
|
||||
/Malformed WidgetEntityId/
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when nodeId segment is missing', () => {
|
||||
expect(() =>
|
||||
parseWidgetEntityId(`widget:${graphId}:42` as WidgetEntityId)
|
||||
).toThrow(/Malformed WidgetEntityId/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWidgetEntityId', () => {
|
||||
const graphId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const graphId = asGraphId('a1b2c3d4-e5f6-7890-abcd-ef1234567890' as UUID)
|
||||
|
||||
it('accepts ids built by the constructor', () => {
|
||||
expect(isWidgetEntityId(widgetEntityId(graphId, 1, 'x'))).toBe(true)
|
||||
expect(isWidgetEntityId(widgetEntityId(graphId, 1 as NodeId, 'x'))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects strings without two colon-separated segments', () => {
|
||||
it('rejects strings lacking the widget: prefix', () => {
|
||||
expect(isWidgetEntityId('only-one-colon:42')).toBe(false)
|
||||
expect(isWidgetEntityId('no-colons')).toBe(false)
|
||||
expect(isWidgetEntityId(':leading-colon:name')).toBe(false)
|
||||
expect(isWidgetEntityId('graph::name')).toBe(false)
|
||||
expect(isWidgetEntityId(`${graphId}:42:seed`)).toBe(false)
|
||||
expect(isWidgetEntityId(`node:${graphId}:42`)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects strings with too few segments', () => {
|
||||
expect(isWidgetEntityId('widget:abc')).toBe(false)
|
||||
expect(isWidgetEntityId(`widget:${graphId}:42`)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects non-strings', () => {
|
||||
@@ -79,3 +91,60 @@ describe('isWidgetEntityId', () => {
|
||||
expect(isWidgetEntityId({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveWidgetEntityId', () => {
|
||||
const graphId = asGraphId('e1d2c3b4-a5f6-1234-5678-90abcdef1234' as UUID)
|
||||
|
||||
it('builds an entity id when all inputs are present', () => {
|
||||
const id = deriveWidgetEntityId(graphId, 5 as NodeId, 'seed')
|
||||
expect(id).toBe(widgetEntityId(graphId, 5 as NodeId, 'seed'))
|
||||
})
|
||||
|
||||
it('returns undefined when graphId is missing', () => {
|
||||
expect(deriveWidgetEntityId(undefined, 5 as NodeId, 'seed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined when nodeId is undefined', () => {
|
||||
expect(deriveWidgetEntityId(graphId, undefined, 'seed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for the sentinel nodeId -1', () => {
|
||||
expect(deriveWidgetEntityId(graphId, -1, 'seed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts a plain UUID for graphId', () => {
|
||||
const plain = 'f0e1d2c3-b4a5-6789-0123-456789abcdef' as UUID
|
||||
expect(deriveWidgetEntityId(plain, 1 as NodeId, 'x')).toBe(
|
||||
widgetEntityId(plain, 1 as NodeId, 'x')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('entityIds type shapes', () => {
|
||||
type GraphId = ReturnType<typeof asGraphId>
|
||||
|
||||
it('widgetEntityId returns the WidgetEntityId brand', () => {
|
||||
expectTypeOf(widgetEntityId).returns.toEqualTypeOf<WidgetEntityId>()
|
||||
})
|
||||
|
||||
it('nodeEntityId returns the NodeEntityId brand', () => {
|
||||
expectTypeOf(nodeEntityId).returns.toEqualTypeOf<NodeEntityId>()
|
||||
})
|
||||
|
||||
it('parseWidgetEntityId returns the documented shape', () => {
|
||||
expectTypeOf(parseWidgetEntityId).returns.toEqualTypeOf<{
|
||||
graphId: GraphId
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
}>()
|
||||
})
|
||||
|
||||
it('WidgetEntityId and NodeEntityId are distinct brands', () => {
|
||||
expectTypeOf<
|
||||
WidgetEntityId extends NodeEntityId ? WidgetEntityId : never
|
||||
>().toEqualTypeOf<never>()
|
||||
expectTypeOf<
|
||||
NodeEntityId extends WidgetEntityId ? NodeEntityId : never
|
||||
>().toEqualTypeOf<never>()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,42 +1,109 @@
|
||||
// TODO: Drop disable once NodeId becomes a branded EntityId owned by src/world/.
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
|
||||
// TODO: Drop disable once UUID moves to src/utils/ (no litegraph coupling).
|
||||
// eslint-disable-next-line import-x/no-restricted-paths
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
/**
|
||||
* Entity IDs are deterministic, content-addressed, string-prefixed values
|
||||
* — not opaque numerics (cf. bitECS, koota, miniplex).
|
||||
*
|
||||
* Identity is keyed by `rootGraph.id`, so an entity viewed at different
|
||||
* subgraph depths shares state. Migrating to numeric IDs would break
|
||||
* cross-subgraph value sharing. See ADR 0008 and `widgetValueStore.ts`.
|
||||
*
|
||||
* The `graph*Prefix` and `*EntityId` helpers below are the sole owners of
|
||||
* the on-the-wire format. Never hand-construct or parse these strings.
|
||||
*/
|
||||
import type { UUID } from '@/utils/uuid'
|
||||
|
||||
import type { Brand } from './brand'
|
||||
|
||||
export type NodeId = number | string
|
||||
|
||||
type GraphId = Brand<UUID, 'GraphId'>
|
||||
|
||||
export function asGraphId(id: UUID): GraphId {
|
||||
return id as GraphId
|
||||
}
|
||||
|
||||
export type NodeEntityId = Brand<string, 'NodeEntityId'>
|
||||
|
||||
function graphNodePrefix(graphId: GraphId): string {
|
||||
return `node:${graphId}:`
|
||||
}
|
||||
|
||||
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
|
||||
return `${graphNodePrefix(graphId)}${nodeId}` as NodeEntityId
|
||||
}
|
||||
|
||||
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
|
||||
|
||||
const SEPARATOR = ':'
|
||||
function graphWidgetPrefix(graphId: GraphId): string {
|
||||
return `widget:${graphId}:`
|
||||
}
|
||||
|
||||
export function widgetEntityId(
|
||||
graphId: UUID,
|
||||
graphId: UUID | GraphId,
|
||||
nodeId: NodeId,
|
||||
name: string
|
||||
): WidgetEntityId {
|
||||
return `${graphId}${SEPARATOR}${nodeId}${SEPARATOR}${name}` as WidgetEntityId
|
||||
return `${graphWidgetPrefix(graphId as GraphId)}${nodeId}:${name}` as WidgetEntityId
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded factory for `WidgetEntityId`. Returns `undefined` when any input
|
||||
* required to construct the id is missing — graphless widgets, unbound
|
||||
* node ids (`-1`), or unknown node ids cannot have a valid entity id.
|
||||
*
|
||||
* Use this from call sites that hold raw widget identity (graphId, nodeId,
|
||||
* name); use the `widget.entityId` getter directly when you already have a
|
||||
* `BaseWidget` instance.
|
||||
*/
|
||||
export function deriveWidgetEntityId(
|
||||
graphId: UUID | GraphId | undefined,
|
||||
nodeId: NodeId | undefined,
|
||||
name: string
|
||||
): WidgetEntityId | undefined {
|
||||
if (!graphId || nodeId === undefined || nodeId === -1) return undefined
|
||||
return widgetEntityId(graphId, nodeId, name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `WidgetEntityId` into its constituent parts.
|
||||
*
|
||||
* On-the-wire format: `widget:${graphId}:${nodeId}:${name}`. The regex
|
||||
* captures the first two colon-delimited segments as graphId and nodeId,
|
||||
* then takes the rest as the widget name — so widget names may contain
|
||||
* colons (e.g. `images.image:0`). NodeId values containing colons split
|
||||
* at the first colon; production NodeIds are scalar-shaped, so this is a
|
||||
* documented edge case rather than a defect. Throws on malformed input
|
||||
* so upstream cast bugs surface at the parse site.
|
||||
*/
|
||||
const WIDGET_ID_RE = /^widget:([^:]+):([^:]+):(.*)$/
|
||||
|
||||
export function parseWidgetEntityId(id: WidgetEntityId): {
|
||||
graphId: UUID
|
||||
graphId: GraphId
|
||||
nodeId: NodeId
|
||||
name: string
|
||||
} {
|
||||
const firstColon = id.indexOf(SEPARATOR)
|
||||
const secondColon = id.indexOf(SEPARATOR, firstColon + 1)
|
||||
const match = WIDGET_ID_RE.exec(id)
|
||||
if (!match) {
|
||||
throw new Error(`Malformed WidgetEntityId: ${id}`)
|
||||
}
|
||||
const [, graphId, nodeId, name] = match
|
||||
return {
|
||||
graphId: id.slice(0, firstColon),
|
||||
nodeId: id.slice(firstColon + 1, secondColon),
|
||||
name: id.slice(secondColon + 1)
|
||||
graphId: graphId as GraphId,
|
||||
nodeId: nodeId as NodeId,
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
export function isWidgetEntityId(value: unknown): value is WidgetEntityId {
|
||||
if (typeof value !== 'string') return false
|
||||
const firstColon = value.indexOf(SEPARATOR)
|
||||
if (firstColon <= 0) return false
|
||||
const secondColon = value.indexOf(SEPARATOR, firstColon + 1)
|
||||
return secondColon > firstColon + 1
|
||||
return typeof value === 'string' && WIDGET_ID_RE.test(value)
|
||||
}
|
||||
|
||||
export function isNodeIdForGraph(graphId: GraphId, id: NodeEntityId): boolean {
|
||||
return id.startsWith(graphNodePrefix(graphId))
|
||||
}
|
||||
|
||||
export function isWidgetIdForGraph(
|
||||
graphId: GraphId,
|
||||
id: WidgetEntityId
|
||||
): boolean {
|
||||
return id.startsWith(graphWidgetPrefix(graphId))
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
import { widgetEntityId } from './entityIds'
|
||||
import {
|
||||
ensureWidgetState,
|
||||
getWidgetState,
|
||||
readWidgetValue,
|
||||
writeWidgetValue
|
||||
} from './widgetValueIO'
|
||||
|
||||
describe('widgetValueIO', () => {
|
||||
const graphA = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
const graphB = 'b1b2c3d4-e5f6-7890-abcd-ef1234567890'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('ensureWidgetState', () => {
|
||||
it('registers a new state when none exists', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const state = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(state.value).toBe(11)
|
||||
expect(state.nodeId).toBe('1')
|
||||
expect(state.name).toBe('seed')
|
||||
})
|
||||
|
||||
it('is idempotent — returns the same state on repeated calls', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
const first = ensureWidgetState(id, init)
|
||||
const second = ensureWidgetState(id, init)
|
||||
expect(second).toBe(first)
|
||||
})
|
||||
|
||||
it('does not overwrite an existing state with init values', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const first = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
first.value = 99
|
||||
const second = ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(second.value).toBe(99)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readWidgetValue / writeWidgetValue', () => {
|
||||
it('round-trips a value through the entity-id surface', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
ensureWidgetState(id, {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
})
|
||||
expect(readWidgetValue(id)).toBe(11)
|
||||
|
||||
expect(writeWidgetValue(id, 22)).toBe(true)
|
||||
expect(readWidgetValue(id)).toBe(22)
|
||||
})
|
||||
|
||||
it('returns false when writing to an unregistered id', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
expect(writeWidgetValue(id, 22)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns undefined when reading an unregistered id', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
expect(readWidgetValue(id)).toBeUndefined()
|
||||
expect(getWidgetState(id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation', () => {
|
||||
it('keeps independent values across distinct entity ids', () => {
|
||||
const id1 = widgetEntityId(graphA, 1, 'seed')
|
||||
const id2 = widgetEntityId(graphA, 2, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 0,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(id1, init)
|
||||
ensureWidgetState(id2, init)
|
||||
|
||||
writeWidgetValue(id1, 11)
|
||||
writeWidgetValue(id2, 22)
|
||||
|
||||
expect(readWidgetValue(id1)).toBe(11)
|
||||
expect(readWidgetValue(id2)).toBe(22)
|
||||
})
|
||||
|
||||
it('isolates values across graph ids', () => {
|
||||
const idA = widgetEntityId(graphA, 1, 'seed')
|
||||
const idB = widgetEntityId(graphB, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 0,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(idA, init)
|
||||
ensureWidgetState(idB, init)
|
||||
|
||||
writeWidgetValue(idA, 11)
|
||||
writeWidgetValue(idB, 22)
|
||||
|
||||
expect(readWidgetValue(idA)).toBe(11)
|
||||
expect(readWidgetValue(idB)).toBe(22)
|
||||
})
|
||||
|
||||
it('matches the legacy triple-keyed API for the same widget', () => {
|
||||
const id = widgetEntityId(graphA, 1, 'seed')
|
||||
const init = {
|
||||
type: 'number',
|
||||
value: 11,
|
||||
options: {},
|
||||
label: undefined,
|
||||
serialize: true,
|
||||
disabled: false
|
||||
}
|
||||
ensureWidgetState(id, init)
|
||||
|
||||
const viaLegacy = useWidgetValueStore().getWidget(graphA, '1', 'seed')
|
||||
expect(viaLegacy).toBe(getWidgetState(id))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import type { WidgetState } from '@/stores/widgetValueStore'
|
||||
|
||||
import { parseWidgetEntityId } from './entityIds'
|
||||
import type { WidgetEntityId } from './entityIds'
|
||||
|
||||
export function getWidgetState(
|
||||
entityId: WidgetEntityId
|
||||
): WidgetState | undefined {
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().getWidget(graphId, nodeId, name)
|
||||
}
|
||||
|
||||
export function readWidgetValue(
|
||||
entityId: WidgetEntityId
|
||||
): WidgetState['value'] | undefined {
|
||||
return getWidgetState(entityId)?.value
|
||||
}
|
||||
|
||||
export function writeWidgetValue(
|
||||
entityId: WidgetEntityId,
|
||||
value: WidgetState['value']
|
||||
): boolean {
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().setValue(graphId, nodeId, name, value)
|
||||
}
|
||||
|
||||
type WidgetStateInit = Omit<WidgetState, 'nodeId' | 'name'>
|
||||
|
||||
export function ensureWidgetState(
|
||||
entityId: WidgetEntityId,
|
||||
init: WidgetStateInit
|
||||
): WidgetState {
|
||||
const existing = getWidgetState(entityId)
|
||||
if (existing) return existing
|
||||
const { graphId, nodeId, name } = parseWidgetEntityId(entityId)
|
||||
return useWidgetValueStore().registerWidget(graphId, {
|
||||
...init,
|
||||
nodeId,
|
||||
name
|
||||
})
|
||||
}
|
||||
19
src/world/widgets/widgetState.ts
Normal file
19
src/world/widgets/widgetState.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface WidgetState<
|
||||
TValue = unknown,
|
||||
TType extends string = string,
|
||||
TOptions extends object = object
|
||||
> {
|
||||
value?: TValue
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
type: TType
|
||||
options: TOptions
|
||||
/**
|
||||
* Whether the widget value is persisted in the workflow JSON
|
||||
* (`widgets_values`). Distinct from `IWidgetOptions.serialize`, which
|
||||
* controls whether the value is included in the API prompt sent for
|
||||
* execution. See `src/lib/litegraph/docs/WIDGET_SERIALIZATION.md`.
|
||||
* @default true
|
||||
*/
|
||||
serialize?: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user