@weasel-js/labkit 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +8 -0
  2. package/dist/_dts/{CanvasStackContext-D6M0o8cQ.d.ts → CanvasStackContext-kjILVnPj.d.ts} +1 -1
  3. package/dist/_dts/PrefsForm.d-CgxUequc.d.ts +20 -0
  4. package/dist/_dts/{frac-QS7XzvpG.d.ts → frac-A4R6v4ld.d.ts} +36 -13
  5. package/dist/_dts/{index-B2X21G1c.d.ts → index-CsU9WhjM.d.ts} +90 -30
  6. package/dist/_dts/{types-ChVJJHvk.d.ts → types-BbXpvQa8.d.ts} +20 -1
  7. package/dist/_dts/{types-C1Zw7s5P.d.ts → types-lg4TSCb2.d.ts} +2 -1
  8. package/dist/_dts/{useTrialState-Cdm13fvl.d.ts → useTrialState-DKjqpv20.d.ts} +5 -1
  9. package/dist/_dts/weasel-canvas-FJTFi3ZZ.d.ts +5041 -0
  10. package/dist/canvas/index.d.ts +16 -16
  11. package/dist/canvas/index.js +2 -2
  12. package/dist/chrome/index.d.ts +15 -9
  13. package/dist/chrome/index.js +5 -5
  14. package/dist/{chunk-7VW3S44V.js → chunk-54ZWZ5FQ.js} +908 -301
  15. package/dist/chunk-54ZWZ5FQ.js.map +1 -0
  16. package/dist/{chunk-Y2HK44TG.js → chunk-D5KQ5OY6.js} +3 -3
  17. package/dist/{chunk-Y2HK44TG.js.map → chunk-D5KQ5OY6.js.map} +1 -1
  18. package/dist/{chunk-VHFQKAUL.js → chunk-FJG4PHTL.js} +83 -75
  19. package/dist/chunk-FJG4PHTL.js.map +1 -0
  20. package/dist/{chunk-LN6JDUGB.js → chunk-HXXTULDN.js} +2 -2
  21. package/dist/{chunk-LN6JDUGB.js.map → chunk-HXXTULDN.js.map} +1 -1
  22. package/dist/{chunk-L5WBGUVR.js → chunk-ISSVF5PT.js} +2753 -2654
  23. package/dist/chunk-ISSVF5PT.js.map +1 -0
  24. package/dist/chunk-NSOVI3AZ.js +170 -0
  25. package/dist/chunk-NSOVI3AZ.js.map +1 -0
  26. package/dist/{chunk-YQGQUBKA.js → chunk-TN7YSJVU.js} +62 -60
  27. package/dist/chunk-TN7YSJVU.js.map +1 -0
  28. package/dist/{chunk-4AMN4MMB.js → chunk-UDXOYZEC.js} +53 -39
  29. package/dist/chunk-UDXOYZEC.js.map +1 -0
  30. package/dist/{chunk-LJSIUFYD.js → chunk-ULDW42CR.js} +23 -2
  31. package/dist/chunk-ULDW42CR.js.map +1 -0
  32. package/dist/{chunk-QR5V3AFG.js → chunk-UTOEDPNU.js} +5 -5
  33. package/dist/{chunk-QR5V3AFG.js.map → chunk-UTOEDPNU.js.map} +1 -1
  34. package/dist/{chunk-UD7TEHZT.js → chunk-W2FJR5FF.js} +35 -22
  35. package/dist/chunk-W2FJR5FF.js.map +1 -0
  36. package/dist/{chunk-B5VWJBWM.js → chunk-WS6ZRV75.js} +3 -3
  37. package/dist/{chunk-B5VWJBWM.js.map → chunk-WS6ZRV75.js.map} +1 -1
  38. package/dist/{chunk-W3KSUXXR.js → chunk-XKENZTNE.js} +45 -14
  39. package/dist/chunk-XKENZTNE.js.map +1 -0
  40. package/dist/controls/index.d.ts +4 -3
  41. package/dist/controls/index.js +3 -3
  42. package/dist/dragdrop/index.d.ts +8 -8
  43. package/dist/dragdrop/index.js +2 -2
  44. package/dist/index.d.ts +75 -110
  45. package/dist/index.js +148 -115
  46. package/dist/index.js.map +1 -1
  47. package/dist/job/index.js +1 -1
  48. package/dist/layers/index.d.ts +26 -11
  49. package/dist/layers/index.js +3 -3
  50. package/dist/loupe/index.d.ts +7 -13
  51. package/dist/loupe/index.js +2 -2
  52. package/dist/passthrough/weasel-canvas.d.ts +3 -343
  53. package/dist/passthrough/weasel-canvas.js +1 -1
  54. package/dist/passthrough/weasel-ui.d.ts +123 -3088
  55. package/dist/passthrough/weasel-ui.js +2 -2
  56. package/dist/primitives/index.js +4 -4
  57. package/dist/state/index.d.ts +3 -3
  58. package/dist/state/index.js +2 -2
  59. package/dist/styles.css +30 -3
  60. package/dist/surface/index.js +2 -2
  61. package/dist/ui/layers/index.d.ts +22 -9
  62. package/dist/ui/layers/index.js +2 -2
  63. package/dist/undo/index.d.ts +6 -5
  64. package/package.json +8 -8
  65. package/src/annotations/AnnotationTargets.tsx +6 -1
  66. package/src/annotations/Annotations.overlay.test.tsx +2 -0
  67. package/src/annotations/types.ts +4 -1
  68. package/src/canvas/CanvasStack.tsx +8 -13
  69. package/src/canvas/useOrbit.test.ts +97 -1
  70. package/src/canvas/useOrbit.ts +41 -34
  71. package/src/canvas/usePanZoom.test.ts +106 -1
  72. package/src/canvas/usePanZoom.ts +54 -36
  73. package/src/chrome/ChromeRegions.stories.tsx +42 -1
  74. package/src/chrome/builtins.test.ts +4 -0
  75. package/src/chrome/builtins.tsx +18 -0
  76. package/src/chrome/index.ts +1 -1
  77. package/src/chrome/regions/SidebarRegion.tsx +7 -6
  78. package/src/chrome/regions/TitleBarRegion.test.tsx +64 -0
  79. package/src/chrome/regions/TitleBarRegion.tsx +16 -7
  80. package/src/chrome/regions/ToolbarRegion.test.tsx +14 -0
  81. package/src/chrome/regions/ToolbarRegion.tsx +2 -2
  82. package/src/chrome/regions/ViewportRegion.tsx +2 -2
  83. package/src/chrome/regions/regions.test.tsx +45 -1
  84. package/src/chrome/types.ts +21 -3
  85. package/src/controls/ControlPanel.test.tsx +38 -0
  86. package/src/controls/ControlPanel.tsx +51 -6
  87. package/src/dragdrop/DragDropRuntime.tsx +74 -61
  88. package/src/dragdrop/Palette.tsx +4 -3
  89. package/src/dragdrop/dragDrop.test.tsx +35 -12
  90. package/src/index.ts +2 -1
  91. package/src/instrument/types.ts +4 -5
  92. package/src/job/useJob.ts +1 -1
  93. package/src/lab/Lab.test.tsx +7 -0
  94. package/src/lab/Lab.tsx +2 -2
  95. package/src/lab/LabContext.ts +5 -1
  96. package/src/lab/LabShell.less +17 -2
  97. package/src/lab/Workspace.tsx +1 -1
  98. package/src/layers/AGENTS.md +29 -7
  99. package/src/layers/LayerList.less +16 -0
  100. package/src/layers/LayerList.stories.tsx +66 -0
  101. package/src/layers/LayerList.test.tsx +185 -2
  102. package/src/layers/LayerList.tsx +221 -74
  103. package/src/layers/index.ts +1 -1
  104. package/src/passthrough/weasel-ui.ts +5 -0
  105. package/src/primitives/FloatingPanel.test.tsx +87 -0
  106. package/src/primitives/FloatingPanel.tsx +59 -41
  107. package/src/state/store.test.ts +78 -0
  108. package/src/state/store.ts +27 -0
  109. package/src/state/types.ts +20 -0
  110. package/src/trial/Trial.less +4 -0
  111. package/src/trial/Trial.targets.test.tsx +96 -0
  112. package/src/trial/Trial.test.tsx +171 -14
  113. package/src/trial/Trial.tsx +4 -1
  114. package/src/trial/TrialChrome.tsx +22 -2
  115. package/src/trial/TrialTitleBar.tsx +7 -3
  116. package/src/trial/index.ts +1 -0
  117. package/src/trial/trialOps.test.ts +49 -1
  118. package/src/trial/trialOps.ts +28 -6
  119. package/dist/_dts/DrawCommand-B3bskUsC.d.ts +0 -564
  120. package/dist/_dts/PrefsForm-BkUJZx0A.d.ts +0 -204
  121. package/dist/_dts/fitViewToBounds-dZ2UDB6e.d.ts +0 -21
  122. package/dist/_dts/shapeKinds-Cx_rxwsa.d.ts +0 -87
  123. package/dist/_dts/types-C-gh9Ap-.d.ts +0 -695
  124. package/dist/chunk-4AMN4MMB.js.map +0 -1
  125. package/dist/chunk-7VW3S44V.js.map +0 -1
  126. package/dist/chunk-L5WBGUVR.js.map +0 -1
  127. package/dist/chunk-LJSIUFYD.js.map +0 -1
  128. package/dist/chunk-UD7TEHZT.js.map +0 -1
  129. package/dist/chunk-VHFQKAUL.js.map +0 -1
  130. package/dist/chunk-W3KSUXXR.js.map +0 -1
  131. package/dist/chunk-XCBCZEBO.js +0 -94
  132. package/dist/chunk-XCBCZEBO.js.map +0 -1
  133. package/dist/chunk-YQGQUBKA.js.map +0 -1
@@ -1,695 +0,0 @@
1
- /**
2
- * An invertible mutation. Applied via an adapter; produces an inverse op
3
- * that, when applied to the same adapter, undoes the original.
4
- *
5
- * Adapters are intentionally typed loosely here so different op types can
6
- * require different adapter capabilities. Each op is responsible for
7
- * narrowing the adapter via the methods it calls.
8
- *
9
- * Lives here rather than in `@weasel-js/core` because an invertible,
10
- * replayable mutation is a history concept: this package is what pushes ops
11
- * onto a stack, inverts them, coalesces them, and rebuilds them from a
12
- * serialized snapshot. Nothing about the shape is core-specific — it names no
13
- * scene, node, or pose type. Core re-exports it from `core/ops/types` so its
14
- * own call sites read unchanged.
15
- */
16
- interface Op {
17
- /** Apply the mutation. Return `false` (or `'noop'`) to signal that
18
- * nothing changed — the history layer then skips pushing an undo
19
- * entry for the batch when *every* op reports no-op. Returning
20
- * `undefined`/`void` means "mutated" (the common case; existing ops
21
- * don't need to change). */
22
- apply(adapter: unknown): void | boolean | 'noop';
23
- invert(): Op;
24
- label?: string;
25
- coalesceKey?: string;
26
- /** Stable factory name for op-registry lookup. Kit-emitted ops always
27
- * set this; consumer ops without a name can't round-trip through
28
- * `History.serialize()` and are dropped from persisted snapshots. */
29
- name?: string;
30
- /** Serializable args (JSON / structured-clone-safe) that, paired with
31
- * `name`, reconstruct the op via the registry's `rebuildOp`. */
32
- args?: unknown;
33
- }
34
-
35
- /** Options for `history.beginJournal()`. */
36
- interface BeginJournalOptions {
37
- /** Label for the single parent-history entry the journal flushes on commit. */
38
- label: string;
39
- /** Caller-supplied tag naming what this journal is scoped to — typically the
40
- * id of the node being edited. The history layer only carries it; callers
41
- * read it back off the journal to decide whether a suspended journal
42
- * matches what they are about to edit. */
43
- targetId?: string;
44
- }
45
- /**
46
- * A scoped sub-history forked from a `History`, opened by
47
- * `history.beginJournal()`. Applies, undoes and redoes against the same
48
- * adapter as its parent, but keeps its entries to itself: `commit` flushes the
49
- * journal's net forward ops to the parent as one entry, `cancel` rewinds them
50
- * and contributes nothing. Use it when a self-contained editing session (a
51
- * text edit, a modal drag) should collapse to a single step in the parent's
52
- * undo stack while still offering undo *within* the session.
53
- *
54
- * A journal is active, suspended or closed. `commit` and `cancel` are
55
- * terminal; `suspend` lets the parent be used again and can be reversed with
56
- * `history.resumeJournal()`. Every mutating method throws when the journal is
57
- * not active.
58
- */
59
- interface Journal {
60
- readonly targetId: string | undefined;
61
- readonly forkedAtEntryId: number;
62
- applyBatch(ops: Op[], label: string): void;
63
- undo(): void;
64
- redo(): void;
65
- canUndo(): boolean;
66
- canRedo(): boolean;
67
- entries(): {
68
- undo: HistoryEntry[];
69
- redo: HistoryEntry[];
70
- };
71
- commit(label: string): void;
72
- cancel(): void;
73
- suspend(): void;
74
- isActive(): boolean;
75
- }
76
-
77
- /** Wire form of a single op inside a serialized history. The pair
78
- * `(name, args)` reconstructs a live `Op` via the op-factory registry. */
79
- interface SerializedOp {
80
- name: string;
81
- args: unknown;
82
- }
83
- /** Wire form of one history entry. `forwardOps` / `baseOps` mirror the
84
- * in-memory entry's fields (see `Entry` above) but only carry the
85
- * serializable `(name, args)` projection of each op. */
86
- interface SerializedHistoryEntry {
87
- id: number;
88
- label: string;
89
- forwardOps: SerializedOp[];
90
- baseOps: SerializedOp[];
91
- selectionBefore?: readonly string[];
92
- selectionAfter?: readonly string[];
93
- }
94
- /** Snapshot of an entire `History` instance. Designed to live alongside the
95
- * scene snapshot in IDB so a reload restores the undo / redo stacks to
96
- * exactly where they were. */
97
- interface SerializedHistory {
98
- version: 1;
99
- undoStack: SerializedHistoryEntry[];
100
- /** Stored newest-first, mirroring the in-memory stack so a deserialized
101
- * history matches the original's `entries().redo` ordering. */
102
- redoStack: SerializedHistoryEntry[];
103
- nextEntryId: number;
104
- /** Entries dropped because at least one of their ops lacked a `name`
105
- * and therefore couldn't round-trip through the op-factory registry.
106
- * Always present (zero when nothing was dropped) so callers can detect
107
- * loss without parsing the debug log. */
108
- droppedEntries: number;
109
- }
110
- /** Read-only view of a history entry exposed via `History.entries()`. */
111
- interface HistoryEntry {
112
- /** Stable monotonic id (preserved across coalesce merges). */
113
- id: number;
114
- /** Human-readable label (the `label` arg passed to `applyOps`). */
115
- label: string;
116
- /** Push/last-coalesce timestamp (ms). */
117
- timestamp: number;
118
- /** Set of node ids touched by any op in this entry. Populated from ops
119
- * whose `args` carry an `id` field (transform, setPath, reparent) or a
120
- * `node.id` field (insert, delete). Ops without a recognisable id field
121
- * contribute nothing. May be `undefined` for deserialized entries
122
- * restored from an older snapshot that predates this field. */
123
- touchedIds?: ReadonlySet<string>;
124
- /** Selection restored when this entry is undone. */
125
- selectionBefore?: readonly string[];
126
- /** Selection restored when this entry is redone. */
127
- selectionAfter?: readonly string[];
128
- }
129
- /** Op-batched undo/redo controller returned by `createHistory`. */
130
- interface History {
131
- apply(op: Op, label?: string): void;
132
- applyOps(ops: Op[], label: string): void;
133
- undo(): void;
134
- redo(): void;
135
- canUndo(): boolean;
136
- canRedo(): boolean;
137
- /** Number of entries on the undo stack (O(1); `entries().undo.length`
138
- * without materializing the views). */
139
- undoDepth(): number;
140
- /** Number of entries on the redo stack (O(1)). */
141
- redoDepth(): number;
142
- clear(): void;
143
- /** Snapshot of the current undo + redo stacks. `undo` is oldest→newest
144
- * (i.e. the last element is what `undo()` would pop next); `redo` is
145
- * also oldest→newest from the user's perspective (i.e. the *first*
146
- * element is what `redo()` would pop next — see implementation note).
147
- * Callers should treat the arrays as immutable. */
148
- entries(): {
149
- undo: HistoryEntry[];
150
- redo: HistoryEntry[];
151
- };
152
- /** Walk the history forward/back until exactly `n` entries are on the
153
- * undo stack (0 ≤ n ≤ entries().undo.length + entries().redo.length).
154
- * Equivalent to repeated `undo()`/`redo()` calls but doesn't bother
155
- * rebuilding entry snapshots between steps. No-op if already at `n`. */
156
- goto(n: number): void;
157
- /** Monotonic counter bumped on every push/undo/redo/clear/coalesce.
158
- * Cheap to read; callers use it as a React dep to detect changes. */
159
- getVersion(): number;
160
- /** Subscribe to history changes. Fires after every push/undo/redo/
161
- * clear/coalesce. Returns an unsubscribe fn. */
162
- subscribe(listener: () => void): () => void;
163
- /** Snapshot the undo + redo stacks in a structured-clone-safe form.
164
- * Entries whose ops aren't all kit-registered (i.e. any op missing a
165
- * `name`) are dropped from the snapshot with a debug-level log — they
166
- * can't round-trip, so we omit them rather than emit a half-restorable
167
- * entry. The in-memory stacks aren't modified. */
168
- serialize(): SerializedHistory;
169
- /** Replace the current undo + redo stacks with the deserialized contents
170
- * of `snapshot`. Ops are rebuilt via the `rebuildOp` option when
171
- * provided, then the global registry; unknown names become no-op
172
- * placeholders so stack ordering survives across kit-version skew.
173
- * Bumps `version` and notifies subscribers exactly once. */
174
- restore(snapshot: SerializedHistory): void;
175
- /** Push an entry whose ops have already been applied to the adapter.
176
- * Unlike `applyOps`, does NOT call `op.apply()`. Used by Journal.commit
177
- * to flush a session's net forward ops to the parent as one entry without
178
- * re-mutating the scene. */
179
- recordEntry(ops: Op[], label: string, options?: RecordEntryOptions): void;
180
- /** Concatenated forwardOps of every undo-stack entry, in order. Snapshot
181
- * of "what changes are currently applied via this history" — useful for
182
- * Journal.commit to flush to a parent, and for any caller that wants to
183
- * diff against a baseline. */
184
- allForwardOps(): Op[];
185
- /** The id that will be assigned to the *next* pushed entry. Stable
186
- * monotonic counter; callers use it to tag a fork point (see Journal). */
187
- currentEntryId(): number;
188
- /** Open a scoped sub-history. All apply/undo/redo on the returned Journal
189
- * affect the same adapter; on commit, the Journal's net forward ops are
190
- * flushed to this History as one entry. See spec docs/superpowers/specs/
191
- * 2026-05-24-modality-design.md for the full lifecycle. */
192
- beginJournal(opts: BeginJournalOptions): Journal;
193
- /** Re-activate a suspended journal. Throws if the journal was committed or
194
- * cancelled (those are terminal), or if a different journal is currently
195
- * active — at most one journal writes to the adapter at a time, on resume
196
- * as well as on open. Staleness checking is the caller's
197
- * responsibility — consult `journal.forkedAtEntryId` against
198
- * `currentEntryId()` and your own op-semantic rules to decide whether
199
- * to resume or discard before calling this. */
200
- resumeJournal(journal: Journal): void;
201
- }
202
- /** Options for `recordEntry`. */
203
- interface RecordEntryOptions {
204
- /** Selection as of before the already-applied ops ran. `recordEntry` is
205
- * called after the fact, so the live selection has moved on by then and
206
- * the engine cannot sample it — a caller that wants undo to restore the
207
- * selection captures it when the batch opens and passes it here. */
208
- selectionBefore?: readonly string[];
209
- }
210
-
211
- /**
212
- * Path data model. Vector-graphics primitive used kit-wide as the canonical
213
- * shape (replacing per-shape ad-hoc poses). SVG-style command stream so we
214
- * cover lines, polygons, beziers, and multi-contour shapes (think the inner
215
- * hole of an "O") under one type.
216
- *
217
- * Storage: command codes in a `Uint8Array`, parameters in a `Float32Array`.
218
- * Each command consumes a fixed number of float coords from the parameter
219
- * array; the parser walks both arrays in lockstep. This keeps interaction
220
- * hot loops monomorphic and keeps GC pressure low under heavy edits.
221
- *
222
- * Two path subtypes are distinguished at the type level so common-case
223
- * machinery (selection AABBs, area-select intersection, hit-testing rect
224
- * silhouettes) can short-circuit on `RectPath` without paying the polygon
225
- * kernel cost. Polymorphic kernels accept either via the `Path` union and
226
- * dispatch on `kind`.
227
- */
228
-
229
- /** Fill rule used by polygon path hit-testing and `ctx.fill()`. */
230
- type PathFillRule = 'nonzero' | 'evenodd';
231
- /**
232
- * Polygon path with arbitrary contours and optional bezier segments.
233
- * Multi-contour: each `M` opens a new subpath; `Z` closes the current one.
234
- * Open subpaths (no `Z`) render as polylines and don't contribute to fills.
235
- */
236
- interface PolygonPath {
237
- kind: 'polygon';
238
- commands: Uint8Array;
239
- coords: Float32Array;
240
- fillRule: PathFillRule;
241
- }
242
- /**
243
- * Axis-aligned rectangle. Fast path for the (very common) case where the
244
- * shape is just a rect — preserves O(1) bounds and hit-test, avoids the
245
- * polygon kernel entirely. Promote to `PolygonPath` only when the shape
246
- * grows beyond what a rect can express.
247
- */
248
- interface RectPath {
249
- kind: 'rect';
250
- x: number;
251
- y: number;
252
- width: number;
253
- height: number;
254
- }
255
- /** Canonical path shape — either an axis-aligned rect (fast path) or a polygon command stream. */
256
- type Path = PolygonPath | RectPath;
257
-
258
- /**
259
- * Axis-aligned rectangle pose with optional rotation. The canonical pose
260
- * shape used by `composeRectPose` and the `unionBounds` helper. Rotation is
261
- * in radians, pivoted on the unrotated AABB center; absent === 0. Kit-side
262
- * code that consumes rotation already reads `pose.rotation ?? 0`
263
- * (`wrapNodeOutput`, `rotate/handle.ts`, `pathInWorld.ts`), so
264
- * the slot exists on every default scene whether or not the consumer
265
- * populates it.
266
- */
267
- interface RectPose {
268
- x: number;
269
- y: number;
270
- width: number;
271
- height: number;
272
- /** Rotation in radians around the unrotated AABB center. Absent === 0. */
273
- rotation?: number;
274
- }
275
- /**
276
- * # SceneNode — the thing in the scene
277
- *
278
- * A `SceneNode` is the single canonical unit of a weasel scene. Everything
279
- * the user sees on the canvas — a shape, a group, an annotation, a tile —
280
- * is one of these. Containers and leaves are both nodes; the kit has no
281
- * other concept of "scene element."
282
- *
283
- * ## Three orthogonal slots
284
- *
285
- * Every node carries three independent slots, plus its tree position:
286
- *
287
- * - **`data: TData`** — app-defined payload. The kit never inspects it.
288
- * Color, label, kind, glyph, sample-rate, whatever the app's domain
289
- * calls for. Mutated via `Scene.update(id, { data })`.
290
- *
291
- * - **`pose: TPose`** — local transform, relative to the node's direct
292
- * parent (or world, for root nodes). Default `RectPose` is
293
- * `{ x, y, width, height }`, but `TPose` is fully generic so apps can
294
- * use rotated rects, paths, ellipses, etc. The kit composes world
295
- * poses via `composeWorldPose` when rendering / hit-testing / snapping.
296
- *
297
- * - **`layer: TLayer`** — a string tag associating the node with a
298
- * visual `RenderLayer` at draw time. Separate from `LayerRecord` (the
299
- * per-layer visible/locked metadata held by the `Scene`).
300
- *
301
- * Tree position lives on the node itself: every node has a `parent` (or
302
- * `null` for roots), and `ContainerNode` adds an ordered `children: NodeId[]`.
303
- *
304
- * ## Identity is by `NodeId`, not by reference
305
- *
306
- * Nodes are addressed by `NodeId` everywhere outside the scene tree:
307
- * selection is `NodeId[]`, ops reference `NodeId`s, adapter methods accept
308
- * `string` ids and look up the node on demand. The `Node` object itself is
309
- * a snapshot of current state — don't hold references to it across scene
310
- * updates; look up by id when you need the latest.
311
- *
312
- * Picking helpers (`pickBest`, `pickEvery`) likewise return ids, not nodes —
313
- * they're hit-testing primitives that stay ignorant of node payload shape.
314
- *
315
- * ## Vocabulary
316
- *
317
- * The kit-internal name is `Node`; the public re-export is `SceneNode`
318
- * (avoids collision with DOM `Node` at call sites). Adapter methods speak
319
- * the same vocabulary: `getNode`, `getNodes`, `insertNode`, `removeNode`,
320
- * `cloneNode`, `addNode`. Older code, demos, and comments may still say
321
- * "object" or "item" — those are historical aliases for the same concept.
322
- */
323
- /** Opaque branded id. Treat as opaque outside the kit. */
324
- type NodeId = string & {
325
- readonly __brand: 'NodeId';
326
- };
327
- interface NodeBase<TData, TLayer extends string, TPose> {
328
- id: NodeId;
329
- layer: TLayer;
330
- pose: TPose;
331
- data: TData;
332
- parent: NodeId | null;
333
- /** Nodes whose poses this node's geometry is computed from. Fixed at add
334
- * time. Absent or empty means the node's geometry is authored, which is the
335
- * normal case. */
336
- dependsOn?: readonly NodeId[];
337
- /** Computes this node's path from its dependencies' poses, in `dependsOn`
338
- * order. A dependency that has been removed arrives as `undefined`.
339
- * Returning `null` means "nothing to draw right now". Re-evaluated when a
340
- * dependency's world pose changes, never authored. Absolute-pose `Scene`
341
- * makes that the dependency's own pose, and an ancestor's move reaches it as
342
- * a `setPose` of its own from the container cascade.
343
- * `node` is deliberately widened: naming `TData`/`TLayer` here puts them in
344
- * a contravariant position, making `Scene` invariant in both and breaking
345
- * assignment kit-wide. The cost is that a `derivePath` casts to read `node.data`. */
346
- derivePath?: (node: Node<unknown, string, TPose>, deps: readonly (TPose | undefined)[]) => Path | null;
347
- }
348
- /** A node with no children — a shape, a label, an image. */
349
- interface LeafNode<TData, TLayer extends string, TPose = RectPose> extends NodeBase<TData, TLayer, TPose> {
350
- kind: 'leaf';
351
- }
352
- /** A node with an ordered list of children. This is the real group: what
353
- * Cmd+G creates, what SVG `<g>` round-trips to. A container has its own pose,
354
- * which its children's poses are relative to, and may optionally clip them. */
355
- interface ContainerNode<TData, TLayer extends string, TPose = RectPose> extends NodeBase<TData, TLayer, TPose> {
356
- kind: 'container';
357
- children: NodeId[];
358
- /** Optional clip-path source. Re-evaluated each render. Returning `null`
359
- * means "no clip for this container right now"; an empty / zero-area path
360
- * means "clip everything out" (children render nowhere). When set, the
361
- * renderer rasterizes the returned path into the stencil buffer and
362
- * paints descendants only where it covers. */
363
- clipFromPose?: (pose: TPose) => Path | null;
364
- }
365
- /** A node in the scene tree: either a leaf or a container. Re-exported
366
- * publicly as `SceneNode`, to avoid colliding with the DOM's `Node`. */
367
- type Node<TData, TLayer extends string, TPose = RectPose> = LeafNode<TData, TLayer, TPose> | ContainerNode<TData, TLayer, TPose>;
368
- interface LayerRecordBase<TLayer extends string> {
369
- id: TLayer;
370
- visible: boolean;
371
- locked: boolean;
372
- }
373
- /** A layer declared when the scene was created. Fixed set, no display name —
374
- * these are the kit's own render bands, not something a user manages. */
375
- interface SystemLayerRecord<TLayer extends string> extends LayerRecordBase<TLayer> {
376
- kind: 'system';
377
- }
378
- /** A layer the user created and can rename, reorder or delete. */
379
- interface UserLayerRecord<TLayer extends string> extends LayerRecordBase<TLayer> {
380
- kind: 'user';
381
- name: string;
382
- }
383
- /** Per-layer metadata held by the scene: whether it is visible and locked,
384
- * and where it sits in the render stack. Distinct from a node's `layer` tag,
385
- * which merely names one of these. */
386
- type LayerRecord<TLayer extends string> = SystemLayerRecord<TLayer> | UserLayerRecord<TLayer>;
387
- /** What `Scene.add` needs to mint a node. Everything except the id is
388
- * required; the id is generated unless one is supplied. */
389
- interface AddNodeSpec<TData, TLayer extends string, TPose = RectPose> {
390
- kind: 'leaf' | 'container';
391
- layer: TLayer;
392
- pose: TPose;
393
- data: TData;
394
- parent?: NodeId | null;
395
- index?: number;
396
- /** Explicit id wins over the Scene's `generateId` and the kit default. */
397
- id?: NodeId;
398
- /** Only meaningful when `kind === 'container'`. Attach a clip-path function
399
- * to the node; ignored for leaves. Mirrors `ContainerNode.clipFromPose`. */
400
- clipFromPose?: (pose: TPose) => Path | null;
401
- /** Mirrors `SceneNode.dependsOn`. */
402
- dependsOn?: readonly NodeId[];
403
- /** Mirrors `SceneNode.derivePath`. Taken as a live function; its registry key is
404
- * looked up from it, never passed in. */
405
- derivePath?: (node: Node<unknown, string, TPose>, deps: readonly (TPose | undefined)[]) => Path | null;
406
- }
407
- /** A custom scene mutation registered with `Scene.registerOp`: how to apply
408
- * it and how to undo it. The pair is what makes it participate in history. */
409
- interface RegisteredOp<P> {
410
- apply: (payload: P) => void;
411
- revert: (payload: P) => void;
412
- }
413
- /** One of the layers a scene is created with. */
414
- interface SystemLayerSpec<TLayer extends string> {
415
- id: TLayer;
416
- visible?: boolean;
417
- locked?: boolean;
418
- }
419
- /** Argument to `Scene.addLayer`. Always produces a `UserLayerRecord`
420
- * (`kind: 'user'`). */
421
- interface AddLayerSpec<TLayer extends string> {
422
- id: TLayer;
423
- name: string;
424
- /** Default `true`. */
425
- visible?: boolean;
426
- /** Default `false`. */
427
- locked?: boolean;
428
- /** Render-stack position. Default: top of stack (highest render index). */
429
- index?: number;
430
- }
431
- /** JSON-serializable shape of a Scene's current state. Produced by
432
- * `scene.toJSON()`; consumed by `sceneFromJSON()`. Function fields
433
- * (e.g., `clipFromPose`) appear as string keys (`clipFromPoseKey`) and
434
- * are resolved through `SceneRegistry` at load time. */
435
- interface SerializedScene<TData, TLayer extends string, TPose> {
436
- version: 1;
437
- systemLayers: readonly SystemLayerSpec<TLayer>[];
438
- nodes: readonly SerializedNode<TData, TLayer, TPose>[];
439
- }
440
- /** JSON-serializable shape of a single node. Mirrors `AddNodeSpec` but
441
- * with function fields replaced by registry keys. */
442
- interface SerializedNode<TData, TLayer extends string, TPose> {
443
- id: string;
444
- kind: 'leaf' | 'container';
445
- layer: TLayer;
446
- pose: TPose;
447
- data: TData;
448
- /** Parent id; omitted for roots. */
449
- parent?: string;
450
- /** Registry key for the container's clip-path factory.
451
- * Containers only; omitted when the container has no clip. */
452
- clipFromPoseKey?: string;
453
- /** Ids this node's geometry derives from. Omitted when it derives from nothing. */
454
- dependsOn?: readonly string[];
455
- /** Registry key for the node's `derivePath` function. Omitted when it has none. */
456
- derivePathKey?: string;
457
- }
458
- /**
459
- * One node's ephemeral presentation override — what a frame loop wants to say
460
- * about a node without saying it about the document.
461
- *
462
- * Not document content: never recorded in history, never in `toJSON`, and
463
- * writing one does not bump `Scene.getVersion()`. Hoist one entry per node and
464
- * mutate it in place on a frame loop; `PoseOverrides.commit()` is what makes a
465
- * mutation visible.
466
- */
467
- interface PoseOverride<TPose> {
468
- /** Replaces the node's document pose everywhere the render and hit-test
469
- * paths read one, including the clip a container derives from its pose.
470
- * Resolved by `effectivePose` — reading `node.pose` directly is how those
471
- * paths came to disagree about where a node is. */
472
- pose?: TPose;
473
- /** Multiplied into the node's painted alpha, on top of any `alphaFor`. */
474
- alpha?: number;
475
- }
476
- /**
477
- * The scene's ephemeral per-node overrides — see {@link PoseOverride}.
478
- *
479
- * The intended shape of a frame is: `set` each node once, mutate the entries
480
- * in place per frame, `commit()` once. `commit` is not optional bookkeeping —
481
- * the painter memo keys on pose *reference*, so a mutation without a commit
482
- * paints the previous frame with no error.
483
- *
484
- * To promote a frame to document state (dropping a drag, baking an animation),
485
- * write it once through `Scene.setPose` and `clear` the override.
486
- */
487
- interface PoseOverrides<TPose> {
488
- /** Store `entry` for `id` **by reference**; the caller keeps mutating it. */
489
- set(id: NodeId, entry: PoseOverride<TPose>): void;
490
- get(id: NodeId): PoseOverride<TPose> | undefined;
491
- has(id: NodeId): boolean;
492
- /** The overridden ids, as a snapshot array. */
493
- ids(): readonly NodeId[];
494
- clear(id: NodeId): void;
495
- clearAll(): void;
496
- /** Publish this frame's in-place mutations: invalidate the painter memo for
497
- * every overridden node, then notify subscribers. */
498
- commit(): void;
499
- /** Notified after every write. The canvas uses this to repaint without a
500
- * scene version bump. */
501
- subscribe(fn: () => void): () => void;
502
- /** Monotonic write counter. A snapshot for observers that poll. */
503
- getGeneration(): number;
504
- }
505
- /**
506
- * The kit-owned scene tree: nodes, layers, and the undo history over both.
507
- *
508
- * A scene is logical, not visual — it says what exists and where, and nothing
509
- * about how it is painted. Every mutating method is undoable, and reads are
510
- * snapshots rather than live views. Nodes are addressed by `NodeId`; hold ids
511
- * across mutations, not node objects.
512
- *
513
- * Three type parameters keep it domain-agnostic: `TData` is the app's payload,
514
- * which the kit never inspects; `TPose` is the transform shape, `RectPose` by
515
- * default; `TLayer` is the union of layer names.
516
- */
517
- interface Scene<TData, TLayer extends string, TPose = RectPose> {
518
- readonly nodes: ReadonlyMap<NodeId, Node<TData, TLayer, TPose>>;
519
- readonly roots: readonly NodeId[];
520
- readonly layers: readonly LayerRecord<TLayer>[];
521
- get(id: NodeId): Node<TData, TLayer, TPose> | undefined;
522
- childrenOf(id: NodeId): readonly NodeId[];
523
- ancestorsOf(id: NodeId): readonly NodeId[];
524
- renderOrder(): Iterable<NodeId>;
525
- /** The same layer-major sequence as {@link Scene.renderOrder}, as the nodes
526
- * themselves. Prefer this wherever the ids are only going to be resolved
527
- * back to nodes: the traversal already holds them, and re-looking each one
528
- * up was ~40% of the area hit-test's per-node cost. Cached until a
529
- * structural edit, so repeat calls hand back the same array — a snapshot,
530
- * not a live view, and not yours to mutate. */
531
- renderOrderNodes(): readonly Node<TData, TLayer, TPose>[];
532
- add(spec: AddNodeSpec<TData, TLayer, TPose>): NodeId;
533
- /** Delete `id`, its **entire subtree**, and **everything that derives from**
534
- * any of those nodes — a node listing one of them in `dependsOn` goes too,
535
- * along with its own subtree, transitively. A dependent can live anywhere in
536
- * the tree, so this deletes nodes the caller never named and may unlink
537
- * several disjoint subtrees at once. Recorded as one undoable step; `undo()`
538
- * restores every one of them where it was, child order intact. */
539
- remove(id: NodeId): void;
540
- /** {@link remove} over several roots at once, as a **single** undoable step.
541
- * Ids resolve against the tree as it stands at the call, so an id that
542
- * another one would cascade away is absorbed rather than removed twice —
543
- * which is what makes it safe to pass a whole selection. Throws if any id is
544
- * not in the scene; an empty list does nothing and records no step. */
545
- removeMany(ids: readonly NodeId[]): void;
546
- update(id: NodeId, patch: {
547
- data: TData;
548
- }): void;
549
- setPose(id: NodeId, pose: TPose): void;
550
- /** Retag `id` to `layer`. On a **container this cascades**: every descendant
551
- * is moved to the same layer, recorded as a **single** undo step.
552
- *
553
- * Invariants:
554
- * - **Layer floor** — a child may not render below its parent, so retagging
555
- * to a layer *below* the node's parent throws. Retagging to the parent's
556
- * layer or any higher one is allowed; a node with no parent is
557
- * unconstrained.
558
- * - **No-op elision** — setting the layer a node already has does nothing
559
- * and pushes **no** history entry. */
560
- setLayer(id: NodeId, layer: TLayer): void;
561
- /** Reparent `id` under `parent` (or to a root when `parent` is `null`) at
562
- * `index` within the new sibling list, appending when `index` is omitted.
563
- * Siblings are reindexed. Recorded as one undoable step.
564
- *
565
- * Rejected (throws) when:
566
- * - `parent` exists but is a **leaf**, not a container;
567
- * - the move would form a **cycle** — `parent` is `id` itself or one of
568
- * `id`'s own descendants;
569
- * - it would drop `id` **below its new parent's layer** (child may not
570
- * render below its parent).
571
- *
572
- * `move(id, null)` — detaching to a root — is always allowed regardless of
573
- * layer, since a root has no parent to render beneath. */
574
- move(id: NodeId, parent: NodeId | null, index?: number): void;
575
- /** Shift `id` to `index` within its **current** parent's child list. Unlike
576
- * {@link move}, the parent never changes — only sibling order. */
577
- reorder(id: NodeId, index: number): void;
578
- setLayerVisible(layer: TLayer, visible: boolean): void;
579
- setLayerLocked(layer: TLayer, locked: boolean): void;
580
- addLayer(spec: AddLayerSpec<TLayer>): void;
581
- /** Drop a user layer and every node tagged to it, as one undoable step.
582
- * Removal cascades, so this also deletes nodes **on other layers** that
583
- * derive from a node on this one. */
584
- removeLayer(layer: TLayer): void;
585
- renameLayer(layer: TLayer, name: string): void;
586
- moveLayer(layer: TLayer, index: number): void;
587
- registerOp<P>(kind: string, handler: RegisteredOp<P>): void;
588
- recordOp<P>(op: {
589
- kind: string;
590
- payload: P;
591
- }): void;
592
- /** Install (or clear) the active-journal accessor after scene construction.
593
- * Useful when the journal source (typically a mode machine) is built
594
- * with `scene.history` as a dependency — a chicken-and-egg situation
595
- * where the accessor can't be passed in via `UseSceneOptions`.
596
- *
597
- * Pass `null` to detach. Overrides any `getActiveJournal` set in
598
- * `UseSceneOptions`. */
599
- setActiveJournalAccessor(fn: (() => Journal | null) | null): void;
600
- /** Apply a batch of ops with journal-aware routing.
601
- *
602
- * - **Without active journal** (or no `getActiveJournal` in options):
603
- * the ops themselves are recorded as one undo entry on the scene's own
604
- * history, rebound to `adapter` — undo replays each op's `invert()`
605
- * against that same adapter. Consecutive `applyBatch` entries can
606
- * coalesce via matching op `coalesceKey`s when the scene opts into
607
- * `coalesceWindowMs`.
608
- * - **With active journal**: routes ops to `journal.applyBatch(ops, label)`.
609
- * The scene's history recording is suppressed for the duration so the
610
- * journal's inner history — not the scene's undo stack — tracks the batch.
611
- * Mutations still happen on `adapter` / scene state.
612
- *
613
- * `adapter` must be the same adapter the ops expect (typically a
614
- * `SceneCanvasAdapter`). Pass `this` from `sceneToAdapter` or a compatible
615
- * adapter. */
616
- applyBatch(ops: Op[], label: string, adapter: unknown): void;
617
- /** The transient set of active ids — "operate on these N as a unit".
618
- * Shared by every view over this scene unless a view supplies its own
619
- * (see `CanvasView.selection`). Not document content: it never appears
620
- * in `toJSON`. It does ride on history entries, so undo and redo put
621
- * back the selection an edit was made under; changing it is never an
622
- * undo step of its own. */
623
- getSelection(): readonly NodeId[];
624
- setSelection(ids: readonly NodeId[]): void;
625
- /** Per-node pose / alpha overrides the render and hit-test paths read
626
- * through. Like {@link Scene.getSelection} this is not document content:
627
- * writes are never recorded, never serialized, and do not bump
628
- * {@link Scene.getVersion}. See {@link PoseOverrides}. */
629
- readonly overrides: PoseOverrides<TPose>;
630
- undo(): boolean;
631
- redo(): boolean;
632
- canUndo(): boolean;
633
- canRedo(): boolean;
634
- batch<T>(label: string, fn: () => T): T;
635
- /** Read-only snapshot of every history entry currently reachable from
636
- * the present state. Oldest applied first, then redoable entries in
637
- * the order they'd be re-applied. Each entry id is stable. */
638
- historyEntries(): readonly {
639
- id: string;
640
- label: string;
641
- }[];
642
- /** Index of the "current state". Equals the count of applied entries;
643
- * `0` means "nothing applied" (initial). */
644
- historyIndex(): number;
645
- /** Jump to the given history index by calling undo/redo repeatedly.
646
- * Clamps to [0, total]. Returns true if any movement occurred. */
647
- jumpToHistoryIndex(index: number): boolean;
648
- /** Snapshot the undo/redo history in a JSON-serializable form (the
649
- * engine's `SerializedHistory`). Entries containing any nameless op are
650
- * dropped (hand-rolled anonymous ops passed to `applyBatch`); kit and
651
- * consumer-registered ops always carry names. Payload JSON-safety
652
- * (e.g. typed arrays inside poses) is the caller's concern. Do not call
653
- * mid-`batch` — the open batch's ops are not yet recorded. */
654
- serializeHistory(): SerializedHistory;
655
- /** Replace the undo/redo history from a `serializeHistory()` snapshot.
656
- * Call on a scene whose node/layer state already matches the snapshot's
657
- * head state (i.e. right after `loadState` from the paired scene
658
- * snapshot); node state is NOT mutated. Ops re-registered via
659
- * `registerOp` before this call round-trip; unknown kinds become no-op
660
- * placeholders; external ops rebuild via the global op-factory registry
661
- * and replay against the `setHistoryAdapter` accessor. Restored entries
662
- * never coalesce with new ones. Notifies once. Do not call mid-`batch`:
663
- * the stacks are replaced underneath the open batch, whose eventual
664
- * flush would graft onto (and evict against) the restored stacks. */
665
- restoreHistory(snapshot: SerializedHistory): void;
666
- /** Install (or clear with `null`) the accessor for the adapter that
667
- * RESTORED external ops (recorded via `applyBatch`, rebuilt from a
668
- * `restoreHistory` snapshot) apply against on undo/redo. Resolved lazily
669
- * at each apply, so wiring order relative to `restoreHistory` doesn't
670
- * matter. Live `applyBatch` entries are unaffected (they bind their
671
- * call-site adapter). If unset when a restored op applies, the op is a
672
- * debug-warned no-op. */
673
- setHistoryAdapter(fn: (() => unknown) | null): void;
674
- /** Snapshot the current scene state to a JSON-serializable shape.
675
- * History (undo/redo stacks) is NOT captured. Function fields like
676
- * `ContainerNode.clipFromPose` are translated to string keys via the
677
- * scene's registry; throws if any function field has no matching key. */
678
- toJSON(): SerializedScene<TData, TLayer, TPose>;
679
- /** Replace this scene's entire node + layer state in place from a snapshot
680
- * produced by `toJSON()`. Unlike `sceneFromJSON`, the existing Scene
681
- * instance is preserved — holders such as `<SceneCanvas>` keep their
682
- * reference. History (undo/redo) is cleared, matching `sceneFromJSON`.
683
- * Bumps `getVersion()` and notifies subscribers exactly once.
684
- *
685
- * Throws on an unsupported version or unknown registry/layer ids; on a
686
- * malformed snapshot the scene is left empty or partially populated (callers should treat a
687
- * `loadState` throw as fatal and reload). Snapshots from `toJSON()` are
688
- * always well-formed. */
689
- loadState(json: SerializedScene<TData, TLayer, TPose>): void;
690
- subscribe(listener: () => void): () => void;
691
- /** Monotonically increasing version. Snapshot for `useSyncExternalStore`. */
692
- getVersion(): number;
693
- }
694
-
695
- export type { History as H, Node as N, Op as O, Path as P, Scene as S, NodeId as a };