@weasel-js/core 1.2.0 → 1.3.0-pre.0
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.
- package/CHANGELOG.md +1508 -0
- package/dist/{DrawCommand-uNkv5TjO.d.ts → DrawCommand-BnZyQaDc.d.ts} +6 -189
- package/dist/chunk-2KKYDDDD.js +121 -0
- package/dist/chunk-2KKYDDDD.js.map +1 -0
- package/dist/{chunk-J7LUY47N.js → chunk-3FB44DBC.js} +3 -3
- package/dist/{chunk-J7LUY47N.js.map → chunk-3FB44DBC.js.map} +1 -1
- package/dist/{chunk-CKADTCHK.js → chunk-3ZV53ZW2.js} +51 -14
- package/dist/chunk-3ZV53ZW2.js.map +1 -0
- package/dist/{chunk-ADWOB5K2.js → chunk-67KE7SDP.js} +4 -4
- package/dist/{chunk-ADWOB5K2.js.map → chunk-67KE7SDP.js.map} +1 -1
- package/dist/{chunk-CSMF654J.js → chunk-AQS4JGRA.js} +50 -7
- package/dist/chunk-AQS4JGRA.js.map +1 -0
- package/dist/{chunk-HGF5X6MZ.js → chunk-BJN5ZRIJ.js} +2 -2
- package/dist/chunk-BJN5ZRIJ.js.map +1 -0
- package/dist/{chunk-4Z6J4IVH.js → chunk-F74NVEHT.js} +14 -46
- package/dist/chunk-F74NVEHT.js.map +1 -0
- package/dist/{chunk-7KYLIC3E.js → chunk-IFOVTYAK.js} +16611 -15749
- package/dist/chunk-IFOVTYAK.js.map +1 -0
- package/dist/{chunk-UGFFCMQP.js → chunk-MXFSHJOM.js} +4 -4
- package/dist/{chunk-UGFFCMQP.js.map → chunk-MXFSHJOM.js.map} +1 -1
- package/dist/{chunk-BHVYVFGV.js → chunk-ZB7UYJVG.js} +3 -3
- package/dist/{chunk-BHVYVFGV.js.map → chunk-ZB7UYJVG.js.map} +1 -1
- package/dist/clipboard.d.ts +3 -3
- package/dist/clipboard.js +3 -3
- package/dist/clone.d.ts +2 -2
- package/dist/clone.js +3 -3
- package/dist/{geometry-C56YbnfD.d.ts → geometry-Dtt_k6Dq.d.ts} +1 -1
- package/dist/{grid-DqOi0Vgi.d.ts → grid-nnXU4VjN.d.ts} +1 -1
- package/dist/index.d.ts +1510 -1292
- package/dist/index.js +10 -10
- package/dist/insert.d.ts +3 -3
- package/dist/insert.js +1 -1
- package/dist/move.d.ts +4 -4
- package/dist/move.js +3 -3
- package/dist/{options-C6HYCKP7.d.ts → options-CdFl510T.d.ts} +1 -1
- package/dist/{path-B6MMiodD.d.ts → path-JEV2c5If.d.ts} +2 -13
- package/dist/patterns-builtin.d.ts +1 -1
- package/dist/patterns-builtin.js +1 -1
- package/dist/{pointSnapToGrid-BBtn5bus.d.ts → pointSnapToGrid-Dmthv94u.d.ts} +2 -2
- package/dist/{registry-l3JX0Z6b.d.ts → registry-BU1_3L_C.d.ts} +629 -34
- package/dist/renderer.d.ts +38 -37
- package/dist/renderer.js +10 -10
- package/dist/resize.d.ts +4 -4
- package/dist/resize.js +2 -2
- package/dist/routing.d.ts +35 -9
- package/dist/routing.js +1 -1
- package/dist/{stroke-DaxnQ-u7.d.ts → stroke-5pBwOm9s.d.ts} +41 -4
- package/dist/{types-C2R2bjxf.d.ts → types-DIQAisSG.d.ts} +99 -5
- package/dist/{types-DbysOO-2.d.ts → types-XBcDEp3Y.d.ts} +14 -9
- package/dist/{types-BhrifbZ1.d.ts → types-miXHGrZM.d.ts} +1 -1
- package/package.json +8 -6
- package/dist/chunk-4Z6J4IVH.js.map +0 -1
- package/dist/chunk-7KYLIC3E.js.map +0 -1
- package/dist/chunk-CKADTCHK.js.map +0 -1
- package/dist/chunk-CSMF654J.js.map +0 -1
- package/dist/chunk-GVCNT7UH.js +0 -47
- package/dist/chunk-GVCNT7UH.js.map +0 -1
- package/dist/chunk-HGF5X6MZ.js.map +0 -1
- package/dist/paint-types-1fUdZUaH.d.ts +0 -266
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,1513 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.0.0-pre.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bca99e3: Extract the typography layer into `@weasel-js/text`, and the paint vocabulary
|
|
8
|
+
into `@weasel-js/paint` — two new Tier A leaves.
|
|
9
|
+
|
|
10
|
+
`@weasel-js/text` owns the run model, style resolution, `layoutRuns`, wrap and
|
|
11
|
+
measurement. It depends on `@weasel-js/font`, `@weasel-js/geom` and
|
|
12
|
+
`@weasel-js/paint`, and on nothing else: a consumer with its own renderer can
|
|
13
|
+
lay out text without taking the scene graph or a React peer dependency.
|
|
14
|
+
`layoutRuns` is now public — it was previously reachable only from inside core.
|
|
15
|
+
|
|
16
|
+
`@weasel-js/paint` holds `FillStyle`, `Stroke`, gradients, dashes and
|
|
17
|
+
`TextureHandle`. It was the blocker named in the 2026-07-28 font split: the
|
|
18
|
+
layout could not move while its fill type lived in the renderer's graph.
|
|
19
|
+
|
|
20
|
+
`@weasel-js/core` re-exports both surfaces, so its own API is unchanged.
|
|
21
|
+
`Rect` moves to `@weasel-js/geom`, beside `Box`.
|
|
22
|
+
|
|
23
|
+
Breaking for anyone importing these through core's internal paths rather than
|
|
24
|
+
its public entry (`core/paint-types`, `features/text/*`); those paths are gone.
|
|
25
|
+
|
|
26
|
+
Advances and kerning still come from a baked MSDF atlas — laying out from font
|
|
27
|
+
bytes alone needs the metrics seam in
|
|
28
|
+
`docs/superpowers/specs/2026-08-28-text-package-extraction-design.md`.
|
|
29
|
+
|
|
30
|
+
<!-- bump-approved: minor: Mike — two new published packages (@weasel-js/text, @weasel-js/paint) and layoutRuns promoted to public API, on top of ~50 patch changesets carrying new public surface across core, ui and labkit; called explicitly in conversation on 2026-08-29: "tag a minor release and push" -->
|
|
31
|
+
|
|
32
|
+
### Patch Changes
|
|
33
|
+
|
|
34
|
+
- 3386d64: Align, distribute and flip use visual bounds
|
|
35
|
+
|
|
36
|
+
These folded each member's unrotated pose box, so "Align Left" on a selection
|
|
37
|
+
containing a rotated shape lined up the boxes and left the rotated shape's ink
|
|
38
|
+
sticking out past the others. They now work on the visual bounding box, as
|
|
39
|
+
Figma and Illustrator do.
|
|
40
|
+
|
|
41
|
+
Both ends moved together — expanding only the union would have made alignment
|
|
42
|
+
worse, since the delta runs from an edge of the union to the same edge of each
|
|
43
|
+
member's box. The new exported `visualBoundsViaDescriptor(pose, geometry)`
|
|
44
|
+
reads a pose's bounds, recovers its rotation and expands via
|
|
45
|
+
`axisAlignedBounds`; the union folds those with `unionAABB`. The delta is still
|
|
46
|
+
applied as a translation of the stored pose through
|
|
47
|
+
`translatePoseViaDescriptor`, so a shape moves rather than being re-posed.
|
|
48
|
+
|
|
49
|
+
Flip needed only its union pivot changed: mirroring maps a centre and preserves
|
|
50
|
+
size, and an expanded box is concentric with the box it came from.
|
|
51
|
+
|
|
52
|
+
`alignMoveBehavior` folds the dragged selection the same way, so a drag snaps
|
|
53
|
+
by its ink.
|
|
54
|
+
|
|
55
|
+
- ffafb7d: Never let an animation's virtual clock run backwards.
|
|
56
|
+
|
|
57
|
+
`useAnimator` seeds each animation's `lastRealNow` from `now()` at register
|
|
58
|
+
time, then advances its virtual clock by the difference against the timestamp
|
|
59
|
+
the frame loop supplies. Those two share a time origin in a browser, where the
|
|
60
|
+
rAF timestamp and `performance.now()` are both page-relative — but that is a
|
|
61
|
+
browser guarantee, not a universal one, and jsdom starts them roughly 600ms
|
|
62
|
+
apart. The first frame's delta then came out hugely negative and `virtualNow`
|
|
63
|
+
spent dozens of frames climbing back toward zero before a tween advanced at
|
|
64
|
+
all: a 40ms glide took 95 frames and over a second of wall time, growing worse
|
|
65
|
+
the longer the process had been alive.
|
|
66
|
+
|
|
67
|
+
A frame's elapsed time is never negative, so the sample is now clamped at
|
|
68
|
+
zero. Under a shared origin this is a no-op.
|
|
69
|
+
|
|
70
|
+
- ba8b139: Camera animation: `viewport.animatedZoom` now does something
|
|
71
|
+
|
|
72
|
+
`animatedZoom` has been declared on `SceneCanvasProps.viewport` and read by
|
|
73
|
+
nothing; Cmd+=/-/0 was a bare `view.set`. It now routes the discrete zoom steps
|
|
74
|
+
through the kit's `Animator`. Wheel and pinch are unchanged and never animate —
|
|
75
|
+
their input already delivers a sample per frame.
|
|
76
|
+
|
|
77
|
+
Camera animation is a general surface, not a zoom flag. Three ways in, one
|
|
78
|
+
runner behind them:
|
|
79
|
+
|
|
80
|
+
- `useViewAnimation(view, animator?)` — `animate`, `animateToBounds`, `stop`,
|
|
81
|
+
`isAnimating`, `target`.
|
|
82
|
+
- The `view` dep gains optional `animate` / `stopAnimation` / `animationTarget`,
|
|
83
|
+
so any action can glide the camera.
|
|
84
|
+
- `SceneCanvasApi` gains `animateView` / `stopViewAnimation` /
|
|
85
|
+
`isViewAnimating` for fit-to-selection, recenter, or a scripted tour. All
|
|
86
|
+
three are **required** members: anyone hand-implementing `SceneCanvasApi`
|
|
87
|
+
(a test double, a wrapper) has to add them, the way `CanvasExtensionApi`
|
|
88
|
+
grew `getPaintedVersion`.
|
|
89
|
+
|
|
90
|
+
Scale interpolates geometrically and translation is derived from the screen
|
|
91
|
+
point the two views agree on, so a zoom stays anchored instead of drifting and
|
|
92
|
+
each frame changes the view by the same ratio. One animation runs at a time; any
|
|
93
|
+
other view write cancels it, and a cancel leaves the camera where it is rather
|
|
94
|
+
than jumping to the target. On an uncontrolled canvas the whole animation costs
|
|
95
|
+
no React render.
|
|
96
|
+
|
|
97
|
+
**Breaking:** `useViewTween` is removed. `useViewAnimation` keeps its name and
|
|
98
|
+
changes signature — it takes a `{ get, set }` view channel plus an optional
|
|
99
|
+
`Animator`, and `animateTo(from, to, { duration, easing })` becomes
|
|
100
|
+
`animate(to, { ms, easing })`. The `from` argument is gone because the runner
|
|
101
|
+
reads the live view, which is what lets an interrupted camera resume from where
|
|
102
|
+
it actually is instead of snapping back to a captured start. `cancel()` is now
|
|
103
|
+
`stop()`, and `animateToBounds(bounds, currentView, dims, { duration })` is now
|
|
104
|
+
`animateToBounds(bounds, dims, { ms })` — the `currentView` argument goes for
|
|
105
|
+
the same reason `from` does.
|
|
106
|
+
|
|
107
|
+
**Breaking:** `viewport.recenter` and `ViewApi.recenter` widen to
|
|
108
|
+
`() => View | void`. Returning the target view lets Cmd+0 animate there;
|
|
109
|
+
returning nothing keeps the existing behavior. `animatedZoom`'s config fields
|
|
110
|
+
are `ms` / `resetMs` rather than `duration` / `resetDuration`, matching the
|
|
111
|
+
animator's vocabulary.
|
|
112
|
+
|
|
113
|
+
- 3fb3a46: Forward `onFocus` and `onBlur` from the canvas element
|
|
114
|
+
|
|
115
|
+
The canvas is focusable by default (`tabIndex` 0) but exposed no way to
|
|
116
|
+
observe focus, so consumers driving focus-dependent chrome had to attach a
|
|
117
|
+
listener to an ancestor and infer it. Both are now props on `CanvasProps`, and
|
|
118
|
+
so reach `SceneCanvasProps` and the canvas element unchanged.
|
|
119
|
+
|
|
120
|
+
- 67bcb05: Drop four values the canvas layer memo no longer reads
|
|
121
|
+
|
|
122
|
+
`hit-test affordances against the painted chrome state` moved the selection
|
|
123
|
+
overlay to reading bounds off the chrome state at paint time, which left
|
|
124
|
+
`selectedIds`, `multiActive`, `previewToolPose` and `previewToolBounds`
|
|
125
|
+
referenced only by the `layers` memo's dependency array — nothing in the body
|
|
126
|
+
used them. Removing them from the array made all four dead locals, so they go
|
|
127
|
+
too.
|
|
128
|
+
|
|
129
|
+
The memo now rebuilds the layer array on layer/tool/geometry changes rather
|
|
130
|
+
than additionally on every selection and preview-pose change. Selection chrome
|
|
131
|
+
is unaffected: it repaints from chrome state, not from the identity of this
|
|
132
|
+
array.
|
|
133
|
+
|
|
134
|
+
- 47cbb08: A closed subpath's dash no longer seams at its start vertex
|
|
135
|
+
|
|
136
|
+
`splitForDash` flushed the run still open when a closed subpath's walk returned
|
|
137
|
+
to the vertex it started from as its own open sub-polyline, so it and the run
|
|
138
|
+
that began there rendered as two butt-capped ribbons meeting at a point — a
|
|
139
|
+
notch on the corner of any dashed rectangle whose perimeter isn't a whole
|
|
140
|
+
multiple of the pattern. They are joined now, and the join the stroke asked for
|
|
141
|
+
is drawn across the seam like any other corner. A pattern whose first "on"
|
|
142
|
+
length covers the whole perimeter emits a closed ribbon, identical to the
|
|
143
|
+
undashed stroke.
|
|
144
|
+
|
|
145
|
+
- f43e9c2: A derived edge follows the drag that moves its endpoint
|
|
146
|
+
|
|
147
|
+
`move`, `resize` and `rotate` kept their in-flight poses in action-local
|
|
148
|
+
scratch and published them only as `previewIds` / `previewPose`. That surface
|
|
149
|
+
is enough to paint a ghost and size selection chrome, but nothing that asks
|
|
150
|
+
the _scene_ where a node is can see it — and `scenePoseLookup`, which resolves
|
|
151
|
+
a derived node's geometry, asks the scene. So dragging a box left its edge
|
|
152
|
+
anchored to the pre-drag position until the drop, when the commit invalidated
|
|
153
|
+
the dependents and the edge jumped.
|
|
154
|
+
|
|
155
|
+
The three actions now also publish each frame into the scene's ephemeral pose
|
|
156
|
+
overrides (`syncPreviewOverrides` / `dropPreviewOverrides` in
|
|
157
|
+
`interactions/actions/previewOverrides.ts`). Overrides bypass `executeAndLog`,
|
|
158
|
+
so a drag still commits as exactly one undo entry — the reason the actions
|
|
159
|
+
avoided per-frame scene writes in the first place was history, and this writes
|
|
160
|
+
no history. Entries are set once and mutated in place, published with a single
|
|
161
|
+
`commit()` per frame.
|
|
162
|
+
|
|
163
|
+
Picking follows for free: the pick source resolves a derived path through its
|
|
164
|
+
own override-aware `poseOf`, so an edge is grabbable where it is drawn
|
|
165
|
+
mid-gesture rather than where it used to be.
|
|
166
|
+
|
|
167
|
+
`clone` is deliberately untouched — its previews are the new ghosts at the
|
|
168
|
+
drag target, and the originals never move, so nothing derives from a changed
|
|
169
|
+
pose.
|
|
170
|
+
|
|
171
|
+
Also closes the matching gap in the preview-ghost layer, which built a
|
|
172
|
+
container's clip with no derived path and so ghosted a derived container
|
|
173
|
+
without one.
|
|
174
|
+
|
|
175
|
+
Note for anyone with a hand-written `Scene` stand-in: `overrides` is now read
|
|
176
|
+
on every gesture frame. It was already required by the `Scene` contract, but a
|
|
177
|
+
partial fake that omitted it will now throw rather than silently skip.
|
|
178
|
+
|
|
179
|
+
- bb27e83: A derived node is clickable where it paints
|
|
180
|
+
|
|
181
|
+
A node whose geometry comes from `derivePath` had no silhouette and no `ink`:
|
|
182
|
+
`NodeShapeEntry.silhouette` took only `(node, pose)`, and a derived path is
|
|
183
|
+
resolved from the _dependencies'_ poses, which a painter has no handle on. So
|
|
184
|
+
`kit:derived` could not report one, `shapeCoversPoint` read the resulting null
|
|
185
|
+
as "no opinion" and answered `true` everywhere, and picking fell back to the
|
|
186
|
+
node's own pose — for an edge, a zero-sized placeholder at the origin. An edge
|
|
187
|
+
was unpickable, and a derived container contributed no clip.
|
|
188
|
+
|
|
189
|
+
`silhouette` now takes a `NodeSilhouetteCtx` carrying `derivedPath`, on the
|
|
190
|
+
same convention `NodePaintCtx` already uses, and `kit:derived` reports the
|
|
191
|
+
derived path as its silhouette and its declared stroke as its `ink`.
|
|
192
|
+
|
|
193
|
+
Resolving that path needs the scene, so it is the _source_ that answers, not
|
|
194
|
+
the painter: `PickSource.derivedPathOf`, a matching optional argument to
|
|
195
|
+
`buildSceneTree`, and `SceneSlotConfig.derivedPathOf` — the slot already
|
|
196
|
+
carried the derived path a node _paints_, and now also the clip a derived
|
|
197
|
+
container _imposes_, so the live canvas and the headless walk clip alike. The
|
|
198
|
+
bare-adapter paths supply none of them and behave exactly as before.
|
|
199
|
+
|
|
200
|
+
The pre-filter had to move with it. `useSceneSelectTool` grew its region test
|
|
201
|
+
from the node's pose, which for a derived node is the wrong box entirely, so
|
|
202
|
+
the edge was rejected before the shape test could claim it. It now tests the
|
|
203
|
+
derived path when there is one — `poseContains` already reads a path-like pose
|
|
204
|
+
as a path, so this reuses it rather than adding a second reach calculation.
|
|
205
|
+
|
|
206
|
+
`findShapeSilhouette` skips its memo when handed a derived path. That slot is
|
|
207
|
+
keyed on `(node, pose, data)` and cannot see the path, so it would serve one
|
|
208
|
+
caller's silhouette to a caller that passed a different one — the same reason
|
|
209
|
+
`kit:derived` already skips `PAINT_SLOT`.
|
|
210
|
+
|
|
211
|
+
- 6a33c3f: A node's path can be derived from other nodes' poses
|
|
212
|
+
|
|
213
|
+
A node declares `dependsOn: NodeId[]` and a `derivePath` function resolved by key
|
|
214
|
+
through `SceneRegistry`, and the scene walks resolve its path before painting
|
|
215
|
+
rather than it being authored. An edge drawn between two boxes is then an
|
|
216
|
+
ordinary scene node — selectable, styleable, exportable — whose geometry never
|
|
217
|
+
enters undo history. The seam and its traps are in `docs/extending.md`.
|
|
218
|
+
|
|
219
|
+
New surface: `scene.removeMany(ids)`; `dependsOn` and `derivePath` on
|
|
220
|
+
`NodeBase` and on `AddNodeSpec`, which is what a consumer writes;
|
|
221
|
+
`SceneRegistry.derivePath`; `SerializedNode.dependsOn` and
|
|
222
|
+
`SerializedNode.derivePathKey`, both additions to the serialization format;
|
|
223
|
+
`NodePaintCtx.derivedPath`.
|
|
224
|
+
|
|
225
|
+
Deleting a node now deletes everything that derives from it, transitively,
|
|
226
|
+
including those nodes' own subtrees, in one undo entry — so `scene.remove` can
|
|
227
|
+
remove nodes anywhere in the tree that the caller never named, and `removeLayer`
|
|
228
|
+
reaches nodes on other layers. Undo after the built-in **Delete** key does not
|
|
229
|
+
yet restore the cascaded nodes; see "Derived geometry follow-ups" in
|
|
230
|
+
`docs/TODO.md`.
|
|
231
|
+
|
|
232
|
+
**Breaking: `defaultDrawOne` takes `(node, pose, view?, ctx?)`.** The paint
|
|
233
|
+
context moves to a fourth parameter, so a call passing a `NodePaintCtx` third is
|
|
234
|
+
now a type error rather than a silent slide into the `view` slot. The same
|
|
235
|
+
fourth parameter is added to the `SceneViewDrawOne` and `SceneSlotConfig.drawOne`
|
|
236
|
+
callback types, which is not a break: an existing three-parameter implementation
|
|
237
|
+
still satisfies them, and an existing three-argument call still compiles.
|
|
238
|
+
|
|
239
|
+
**Breaking: `Scene` gained a required `removeMany`.** A hand-written object
|
|
240
|
+
typed as a `Scene` — a test double, most likely — no longer typechecks until it
|
|
241
|
+
implements it.
|
|
242
|
+
|
|
243
|
+
**Breaking: `kit:remove`'s op payload changed shape.** `rootId` / `parent` /
|
|
244
|
+
`index` became `detached: { id, parent, index }[]`, because a cascaded dependent
|
|
245
|
+
is not a descendant of the removed node and the tree has to be told about every
|
|
246
|
+
subtree that came out of it. A history persisted by an older build now throws
|
|
247
|
+
mid-undo rather than degrading. The break is deliberate; kit op payloads are not
|
|
248
|
+
versioned.
|
|
249
|
+
|
|
250
|
+
- c24e7de: Detached views follow pose overrides
|
|
251
|
+
|
|
252
|
+
`<SceneViewCanvas>` and `<MinimapCanvas>` re-rendered off `scene.getVersion()`,
|
|
253
|
+
which a pose override deliberately never bumps — so they kept painting document
|
|
254
|
+
poses while `<SceneCanvas>` painted the overridden ones. A minimap beside a
|
|
255
|
+
canvas driving a drag or a simulation silently disagreed with it.
|
|
256
|
+
|
|
257
|
+
`<SceneViewCanvas>` now paints through `useFrameLoop` instead of from React, and
|
|
258
|
+
subscribes to `scene.overrides`. A render (prop change or version bump) and an
|
|
259
|
+
override commit both just mark the surface dirty, and one animation frame
|
|
260
|
+
coalesces them — so a 60 Hz override loop repaints these views with no React
|
|
261
|
+
render, and a backgrounded tab stops painting them entirely. The mount paint
|
|
262
|
+
stays synchronous, so the first frame is still the scene rather than a blank
|
|
263
|
+
canvas. `<MinimapCanvas>` inherits all of this through it.
|
|
264
|
+
|
|
265
|
+
Repaints driven by a prop change are now asynchronous: they land on the next
|
|
266
|
+
animation frame rather than in the layout effect of the render that caused them.
|
|
267
|
+
Code that renders and then reads pixels in the same tick needs to wait a frame.
|
|
268
|
+
|
|
269
|
+
A minimap's _framing_ still derives from document poses, so a node overridden
|
|
270
|
+
outside the document bounds paints outside the fitted frame — recomputing the
|
|
271
|
+
fit per frame would rescale the whole minimap throughout a settle.
|
|
272
|
+
|
|
273
|
+
- ce82f4a: An enum leaf can ask for a segmented control, and `pair` works inside an object
|
|
274
|
+
|
|
275
|
+
`ToolPrefEnumControl` gains `'toggle'`: a three-option enum shows all three at
|
|
276
|
+
once instead of hiding two behind a select. Options carry an optional `short`
|
|
277
|
+
label — a capital or two — for the width a property row has; the full `label`
|
|
278
|
+
stays the accessible name, so the abbreviation never becomes the only thing
|
|
279
|
+
naming the option. A mixed selection selects no segment rather than picking a
|
|
280
|
+
winner.
|
|
281
|
+
|
|
282
|
+
`pair` now merges fields inside an object leaf, as it already did for section
|
|
283
|
+
rows — a hint shouldn't mean something different for being a field of a value
|
|
284
|
+
rather than a sibling of one. It merges _adjacent_ leaves in both places, so
|
|
285
|
+
the schema orders family, size, weight: size and weight pair, and family (which
|
|
286
|
+
sat between them) moves ahead of the pair rather than splitting it.
|
|
287
|
+
|
|
288
|
+
A stroke's cap, join and align share one row; property rows wrap rather than
|
|
289
|
+
overflow when the controls in them don't fit.
|
|
290
|
+
|
|
291
|
+
- be697dc: Add ephemeral pose overrides to the scene
|
|
292
|
+
|
|
293
|
+
`scene.overrides` holds a per-node `{ pose?, alpha? }` that the render and
|
|
294
|
+
hit-test paths read through and that history, `toJSON()` and `getVersion()`
|
|
295
|
+
never see. It is additive: a scene with no overrides behaves exactly as before.
|
|
296
|
+
|
|
297
|
+
This is where per-frame motion belongs. A 60 Hz loop previously had to write
|
|
298
|
+
through `setPose`, which records an undo entry (one per frame at best, batched)
|
|
299
|
+
and bumps the scene version, re-rendering every `useSyncExternalStore`
|
|
300
|
+
subscriber. It also had to allocate a fresh pose object per moving node per
|
|
301
|
+
frame, because the painter memo keys on pose reference. An override entry is
|
|
302
|
+
hoisted once and mutated in place; `overrides.commit()` publishes the frame and
|
|
303
|
+
invalidates the memo for the overridden nodes only.
|
|
304
|
+
|
|
305
|
+
`commit()` is required after an in-place mutation — without it the memo serves
|
|
306
|
+
the previous frame's draw. Overrides are cleared when a node is removed, since
|
|
307
|
+
ids are reusable. To make a frame permanent, write it once through `setPose`
|
|
308
|
+
and clear the override; that single step is the undo entry.
|
|
309
|
+
|
|
310
|
+
`ForceGraphDemo` now settles with zero history entries and bakes the result as
|
|
311
|
+
one, replacing a per-tick batch of 24 `setPose` calls.
|
|
312
|
+
|
|
313
|
+
- e909a3b: `fitTextPose` sizes a box the renderer will actually fill
|
|
314
|
+
|
|
315
|
+
It was the fourth site measuring text its own way: `ctx.measureText` per
|
|
316
|
+
character against system fonts, no kerning, `pose.text` only. Nothing masked
|
|
317
|
+
it the way the WebGL context masked the caret — a consumer calling it got a
|
|
318
|
+
box that disagreed with the paint, narrower by a kern on every pair and wrong
|
|
319
|
+
by the whole difference between the installed family and the registered face.
|
|
320
|
+
It goes through the shared layout now, so it sees kerning and per-run styling.
|
|
321
|
+
|
|
322
|
+
**Breaking:** `fitTextPose(ctx, pose, opts)` is now `fitTextPose(pose, opts)`.
|
|
323
|
+
|
|
324
|
+
- 26bbdcf: Paint the canvas from its own animation frame instead of from a React render
|
|
325
|
+
|
|
326
|
+
`requestRedraw()` marks the surface dirty and the next frame paints, so many
|
|
327
|
+
redraws in one tick cost one paint. The view gains an imperative path on the
|
|
328
|
+
canvas handle — `setView` / `getView` / `subscribeView` — and `SceneCanvas` no
|
|
329
|
+
longer holds it in React state, so a camera moving at 60 Hz costs no renders.
|
|
330
|
+
Consumers passing a `view` prop stay controlled and are unaffected.
|
|
331
|
+
|
|
332
|
+
Opt-ins that come with it: `syncPaint` paints inside the commit for a consumer
|
|
333
|
+
that wants the old whole-cloth guarantee, `useScene(…, { subscribe: false })`
|
|
334
|
+
gives a host the scene without a render per mutation, `useSceneTextEdit`'s
|
|
335
|
+
`view` option accepts a thunk so the overlay tracks a ref-driven camera, and a
|
|
336
|
+
`contentVersion` prop feeds the version that `getPaintedVersion()` reports.
|
|
337
|
+
|
|
338
|
+
Two public signatures changed. `usePinchZoomTool` takes a view getter,
|
|
339
|
+
`getView: () => View`, where it took a `View` — nothing re-renders to refresh a
|
|
340
|
+
captured value any more. `CanvasExtensionApi` gained five required members —
|
|
341
|
+
`getView`, `setView`, `subscribeView`, `subscribeFrame`, `getPaintedVersion` —
|
|
342
|
+
so external code hand-implementing that interface stops typechecking; code that
|
|
343
|
+
only calls through the ref is unaffected.
|
|
344
|
+
|
|
345
|
+
Pixels and DOM can now be a frame apart, in whichever direction the change came
|
|
346
|
+
from. A view change leads with pixels: `setView` paints without rendering, so
|
|
347
|
+
DOM built from the view is stale until something re-renders it — position
|
|
348
|
+
world-anchored DOM from `subscribeView`. A scene change leads with DOM:
|
|
349
|
+
`SceneCanvas` still subscribes to the scene, so a `batch` commits now and the
|
|
350
|
+
pixels land next frame — compare `getPaintedVersion()` against the version you
|
|
351
|
+
are about to render when chrome must be in lockstep. Do not render scene-derived
|
|
352
|
+
DOM inside `startTransition`: React defers it and nothing forces it to catch up.
|
|
353
|
+
|
|
354
|
+
Anything reading the drawing buffer back outside a paint — the hud loupe's pixel
|
|
355
|
+
mode is the one in-tree case — can likewise see a buffer one frame older;
|
|
356
|
+
`subscribeFrame` runs on the frame that painted and removes the lag. Nothing
|
|
357
|
+
paints while `document.hidden` is true, `syncPaint` included, so a readback from
|
|
358
|
+
a background tab returns the frame from before the tab was hidden.
|
|
359
|
+
|
|
360
|
+
- 546f67d: Copy typed-array arguments into `makeGLRecorder`'s call log as they are
|
|
361
|
+
recorded. A caller is entitled to reuse the array it uploads from, so storing
|
|
362
|
+
the reference recorded a value that later frames overwrote — a test reading
|
|
363
|
+
two frames back saw the same numbers twice and passed. Test-only surface.
|
|
364
|
+
- 3fb3a46: Release held keys when the window loses focus
|
|
365
|
+
|
|
366
|
+
A window that blurs mid-hold never delivers the keyup, so every in-flight
|
|
367
|
+
`key-held` handle stayed engaged until that key was pressed again — holding
|
|
368
|
+
Space and tabbing away left the hand tool on the hotkey stack indefinitely.
|
|
369
|
+
|
|
370
|
+
The gesture dispatcher now fires the `key-held` up phase for each held key on
|
|
371
|
+
window blur. Consumers that hand-rolled this reset can drop it; ongoing
|
|
372
|
+
invocations see a normal `onEnd`.
|
|
373
|
+
|
|
374
|
+
- ccd51cc: Add a 43-glyph monochrome icon set to `@weasel-js/ui`.
|
|
375
|
+
|
|
376
|
+
One register: a 20x20 viewBox drawn in `currentColor` at stroke-width 1.5 with
|
|
377
|
+
round caps and joins, hairline weight reserved for structure, and filled
|
|
378
|
+
regions only where an action has a subject. Covers transport, history, view,
|
|
379
|
+
trial lifecycle, collection, state, instrument and status vocabulary. Import a
|
|
380
|
+
named component (`CloneIcon`), or `Icon` when the glyph is chosen at runtime.
|
|
381
|
+
|
|
382
|
+
`@weasel-js/ui` also re-exports the tool glyphs that live in `@weasel-js/core`,
|
|
383
|
+
so consumers have one import site for the whole set. `ImageIcon` was reachable
|
|
384
|
+
from core's icons folder but missing from its public barrel; it is exported
|
|
385
|
+
now.
|
|
386
|
+
|
|
387
|
+
Glyph geometry is generated (`npm run gen:icons`) from `packages/ui/scripts/icons/`
|
|
388
|
+
rather than hand-placed, because arrowheads and joins that miss their terminus
|
|
389
|
+
are invisible at chrome size.
|
|
390
|
+
|
|
391
|
+
- 3fb3a46: Compose `before` and `after` layer chains in both directions
|
|
392
|
+
|
|
393
|
+
`composeOrderedLayers` walked the two anchor maps separately: a chain hanging
|
|
394
|
+
off an `after` anchor only followed further `after` links, and likewise for
|
|
395
|
+
`before`. A custom layer anchored `before: 'scene'` carrying a second custom
|
|
396
|
+
anchored `after` it dropped that second layer to the tail with a spurious
|
|
397
|
+
dangling-reference warning.
|
|
398
|
+
|
|
399
|
+
Both walks now emit a layer's `before` chain, the layer, then its `after`
|
|
400
|
+
chain, so the two mix freely. Cycle detection and orphan fallback are
|
|
401
|
+
unchanged.
|
|
402
|
+
|
|
403
|
+
- d9f110e: Stop every frame loop while nothing can see it
|
|
404
|
+
|
|
405
|
+
New public hook `useVisibleRaf` in `@weasel-js/core` owns the question of
|
|
406
|
+
whether a frame may run: nothing runs while `document.hidden`, and a loop that
|
|
407
|
+
names an element also stops while that element is outside the viewport. A
|
|
408
|
+
request made while suspended is held rather than dropped and re-armed on
|
|
409
|
+
resume, so a loop never polls visibility or needs restarting by hand.
|
|
410
|
+
|
|
411
|
+
Ten loops now run behind it — `useFrameLoop`, `useAnimator`, `useSimulation`,
|
|
412
|
+
`useDecayLoop`, `useTextEdit`'s overlay follow, `CursorCoordsHud`'s FPS
|
|
413
|
+
counter, `Badge`'s crawl, and labkit's `FpsMeter`, `useTiledSurface` and
|
|
414
|
+
`useLayerScheduler`. Only `useFrameLoop` consulted `document.hidden` before;
|
|
415
|
+
the rest ran on any page left open. `useLayerScheduler` looked safe and wasn't:
|
|
416
|
+
it paints only dirty layers, but a hidden tab still commits React updates and
|
|
417
|
+
its view/size effect marks every layer dirty.
|
|
418
|
+
|
|
419
|
+
Loops measuring elapsed time rebase their clock through the new `onResume`
|
|
420
|
+
option, so an hour spent hidden does not arrive as one hour-long frame — an FPS
|
|
421
|
+
meter reporting a rate nobody achieved, a tween jumping to its end value on
|
|
422
|
+
return. `dangerouslyRunWhenHidden` opts a loop out for offscreen recording or
|
|
423
|
+
export; nothing in the tree sets it.
|
|
424
|
+
|
|
425
|
+
`npm run check:frame-loops` fails the build on a bare `requestAnimationFrame`
|
|
426
|
+
in kit source, and runs in CI.
|
|
427
|
+
|
|
428
|
+
- 0dd35a1: Fix pinch-to-zoom: mac trackpads zoomed the page, and `viewport.pinchZoom` zoomed twice
|
|
429
|
+
|
|
430
|
+
A trackpad pinch reaches the page as `wheel { ctrlKey: true }`. On a mac
|
|
431
|
+
`viewport.zoom`'s `mods: { mod: true }` binding requires metaKey and forbids
|
|
432
|
+
ctrl, and `viewport.wheelPan` forbids ctrl too, so nothing claimed the event
|
|
433
|
+
and the browser's own ctrl+wheel page zoom ran. `viewport.zoom` now carries a
|
|
434
|
+
second wheel binding on bare ctrl. Off mac it duplicates the `mod` binding,
|
|
435
|
+
where the matcher picks a single winner.
|
|
436
|
+
|
|
437
|
+
Nothing caught that because `IS_MAC` read `navigator.platform ?? userAgent`,
|
|
438
|
+
and jsdom reports an empty-string platform — not nullish, so the fallback never
|
|
439
|
+
fired and every mac binding in the kit was exercised only on the non-mac
|
|
440
|
+
branch. It reads `||` now.
|
|
441
|
+
|
|
442
|
+
Separately, `viewport.pinchZoom: true` mounted `<Canvas>`'s `usePinchZoomTool`
|
|
443
|
+
alongside the `viewport.pinchZoom` action that already handled the same
|
|
444
|
+
gesture, applying one pinch's factor twice — the opt-in broke the path that
|
|
445
|
+
worked without it. SceneCanvas drives pinch through the action alone, and the
|
|
446
|
+
flag configures it: new `makePinchZoomAction({ min, max })` (exported), with
|
|
447
|
+
the kit's 0.1–8 clamp now applied by default. `pinchZoom: false` disables pinch
|
|
448
|
+
for real; it previously left the action running. Bare `<Canvas>` keeps the hook
|
|
449
|
+
as its own pinch path.
|
|
450
|
+
|
|
451
|
+
- 1a0bea3: `useNodeOverlayFrame`: the coordinate frame a DOM overlay pinned to a node needs
|
|
452
|
+
|
|
453
|
+
Nothing in the kit exported one, so consumers hand-rolled it — their own
|
|
454
|
+
`ResizeObserver` next to the existing `useCanvasSize`, and a translate-and-scale
|
|
455
|
+
inverse built by projecting two points. That inverse silently drops
|
|
456
|
+
`pose.rotation`, which is why on-canvas gradient handles on a rotated node sat
|
|
457
|
+
beside the paint instead of on it.
|
|
458
|
+
|
|
459
|
+
```ts
|
|
460
|
+
useNodeOverlayFrame(scene, containerRef, nodeId, { view });
|
|
461
|
+
// → { box, toScreen, toLocal, width, height } | null
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
`box` is the node's composed world box, unrotated — the frame `toScreen` maps
|
|
465
|
+
from, and the box to hand `fillInPoseFrame` / `fillToBoundsFrame`. Rotation
|
|
466
|
+
lives in the pose→world leg, where it belongs: a node's stored geometry and its
|
|
467
|
+
bounds-frame paint are pre-rotation by definition, so neither of those two
|
|
468
|
+
changes.
|
|
469
|
+
|
|
470
|
+
`@weasel-js/ui` gains `SceneGradientHandles`, the scene-aware half of
|
|
471
|
+
`GradientHandles`: it reads the gradient out of a node's `fill` **or** its
|
|
472
|
+
`stroke` — `slot` is a prop — and commits each drag through `setFill` or
|
|
473
|
+
`setStroke` as one undo entry. `GradientHandles` itself stays frame-agnostic.
|
|
474
|
+
|
|
475
|
+
Also: `isGradientFill` narrows a `FillStyle` to its three gradient members, and
|
|
476
|
+
`useCanvasSize` accepts any `HTMLElement` rather than only a `div`.
|
|
477
|
+
|
|
478
|
+
- 9d95836: A node's `data.stroke` takes a whole `Stroke`, not just a color
|
|
479
|
+
|
|
480
|
+
`NodeStroke = string | Stroke`, mirroring `NodeFill`. A string is still a
|
|
481
|
+
color and `'none'` still skips the stroke; an object is a core `Stroke` whose
|
|
482
|
+
`width`, `cap`, `join`, `dash`, `miterLimit` and `align` all reach the
|
|
483
|
+
renderer, which has accepted them on `PathDrawCommand` all along. The object
|
|
484
|
+
wins outright over `data.strokeWidth` rather than merging with it, the same
|
|
485
|
+
rule `withLeafStroke` already applied to text. A bounds-relative stroke paint
|
|
486
|
+
is baked onto the pose box the way a fill is, so a gradient stroke resolves
|
|
487
|
+
against the box it was authored against.
|
|
488
|
+
|
|
489
|
+
`kit:shape` now honors `stroke: 'none'`, which only `kit:path` checked before.
|
|
490
|
+
|
|
491
|
+
`NodeInk` reports `{ filled, outset, inset }` instead of `{ filled,
|
|
492
|
+
strokeWidth }`: `align: 'inner'` puts no ink outside the silhouette and
|
|
493
|
+
`'outer'` none inside, which one number could not say, so picking grabbed the
|
|
494
|
+
wrong side. `ink` takes an optional context carrying the view scale, so a
|
|
495
|
+
`{ px }` stroke width resolves to world units. A painter that still returns
|
|
496
|
+
`{ filled, strokeWidth }` is read as a centered stroke and keeps working.
|
|
497
|
+
|
|
498
|
+
`setStroke` and `setStrokeOpacity` no longer stringify a node's `Stroke`: a
|
|
499
|
+
color pick replaces its paint and keeps width, cap, join and dash, and an
|
|
500
|
+
opacity drag sets the paint's `opacity`, which is the only form that works on
|
|
501
|
+
a gradient stroke.
|
|
502
|
+
|
|
503
|
+
Editing UI for the rich form is not here yet — a schema-driven color control
|
|
504
|
+
still writes a bare string over the object, so nodes carrying one are for
|
|
505
|
+
programmatic authorship until `SelectionPanel` learns the union. See
|
|
506
|
+
`docs/proposals/2026-08-26-node-stroke-union.md`.
|
|
507
|
+
|
|
508
|
+
- 62a3c46: Paint a gradient or pattern stroke instead of throwing.
|
|
509
|
+
|
|
510
|
+
`Stroke.paint` has always been a full `FillStyle`, and SVG import puts paint
|
|
511
|
+
servers there deliberately, but the renderer refused anything but a solid — so
|
|
512
|
+
importing a shape with `stroke="url(#grad)"` produced a scene that threw on the
|
|
513
|
+
next frame. Both stroke paths now paint the ribbon through the same route a
|
|
514
|
+
fill takes, including under the inner/outer alignment stencil. A non-solid
|
|
515
|
+
even-odd fill no longer renders black.
|
|
516
|
+
|
|
517
|
+
- 5f6c28e: An object leaf's fields can be organised into groups
|
|
518
|
+
|
|
519
|
+
`ToolPrefObject.children` takes a `ToolPrefGroup` as well as a leaf. A group
|
|
520
|
+
heads its fields under a label and contributes nothing to the path — the same
|
|
521
|
+
rule group keys follow at the top level of a schema, so a field inside one is
|
|
522
|
+
still addressed as a field of the object.
|
|
523
|
+
|
|
524
|
+
Without it, a value with many fields renders as one undifferentiated list. A
|
|
525
|
+
`TextStyle` is the case that needs it: its character and paragraph fields are
|
|
526
|
+
one value but read as two lists.
|
|
527
|
+
|
|
528
|
+
- 3cd1ee8: A schema leaf can hold an object, with its fields hanging off it
|
|
529
|
+
|
|
530
|
+
A compound value — a stroke, a shadow, a pattern spec — could be described as
|
|
531
|
+
sibling leaves addressing into it (`data.stroke.width`, `data.stroke.cap`).
|
|
532
|
+
It shouldn't be: each control then writes one field of a value it can only
|
|
533
|
+
half see, and writing a field into something that isn't an object yet corrupts
|
|
534
|
+
it outright.
|
|
535
|
+
|
|
536
|
+
`ToolPrefObject` describes the value instead. Its `children` are ordinary
|
|
537
|
+
leaves whose paths are relative to the object, and every child edit commits
|
|
538
|
+
the parent object whole. A field that is itself a union declares the kind that
|
|
539
|
+
edits that union — a stroke's `paint` is a `paint` leaf. `fromScalar` lifts a
|
|
540
|
+
value still held in a scalar form before a child edit lands on it, which is
|
|
541
|
+
how a stroke stored as a bare colour string gains a width.
|
|
542
|
+
|
|
543
|
+
`defaultNodeProperties` describes `data.stroke` this way, so the panel shows
|
|
544
|
+
Color, Width, Cap, Join and Align under one Stroke block, and the separate
|
|
545
|
+
`data.strokeWidth` leaf is gone. `SelectionPanel` now honours `block`, which
|
|
546
|
+
`PrefsForm` already did. The one-off `stroke` pref kind added days ago is
|
|
547
|
+
replaced by this general one.
|
|
548
|
+
|
|
549
|
+
`dash` has no leaf: it is a `number[]` and no kind edits one. It survives
|
|
550
|
+
import, export and rendering untouched.
|
|
551
|
+
|
|
552
|
+
- 2ea772f: Selection handles are hit-tested at the size they are painted
|
|
553
|
+
|
|
554
|
+
Handles painted at `HANDLE_BASE_PX * targetScale` and hit-tested at the bare
|
|
555
|
+
constant, and neither `buildAffordanceAt` call site passed the option that
|
|
556
|
+
would have scaled it. A coarse pointer got a bigger picture and exactly the
|
|
557
|
+
same 8px grab zone it had on a mouse — the touch forgiveness the coarse profile
|
|
558
|
+
exists to provide never reached the hit-test. The slops debug overlay was a
|
|
559
|
+
third unscaled copy, so it drew hit regions where they were not.
|
|
560
|
+
|
|
561
|
+
`core/device/targets.ts` now holds one base table and one accessor,
|
|
562
|
+
`targetSizesPx(targetScale)`. Paint, hit-test and the debug overlay all resolve
|
|
563
|
+
through it. `HANDLE_BASE_PX`, `ANCHOR_HIT_BASE_PX` and
|
|
564
|
+
`ROTATION_HANDLE_BASE_PX` keep their names and values and now read off the
|
|
565
|
+
table; the internal `HANDLE_HIT_RADIUS` and `ANCHOR_HIT_RADIUS` are gone.
|
|
566
|
+
|
|
567
|
+
`buildAffordanceAt` and `createSlopsDebugLayer` take an optional `targetScale`.
|
|
568
|
+
`selectTool.handleHitRadius` now actually reaches the hit-test — it previously
|
|
569
|
+
reached nothing.
|
|
570
|
+
|
|
571
|
+
`useRotateTool`'s `handleHitRadius` option is **removed**. The rotation
|
|
572
|
+
affordance is an annulus with a band thickness and no point radius, so the
|
|
573
|
+
option could only ever have been a second name for `rotationHandleDistance`,
|
|
574
|
+
which is live and now defaults from the same table.
|
|
575
|
+
|
|
576
|
+
Known gap: `CanvasView` is a second `buildAffordanceAt` call site that reads no
|
|
577
|
+
device profile, so a nested view still hit-tests at the fine-pointer size.
|
|
578
|
+
|
|
579
|
+
- f77bd95: `getChildren` means one thing on an adapter
|
|
580
|
+
|
|
581
|
+
`MoveAdapter` declared `getChildren(id)` — a node's direct children, for the
|
|
582
|
+
drag cascade — and `OrderedAdapter` declared `getChildren(parentId | null)`,
|
|
583
|
+
the z-ordering seam where `null` means the root. Both land on the same adapter
|
|
584
|
+
object, so `arrayAdapter` took the first shape from its config and exposed it
|
|
585
|
+
under the name the ops read with the second meaning. An op asking for root
|
|
586
|
+
order got `[]`, which reads as "the root has no siblings", and the slot it
|
|
587
|
+
captured was silently lost.
|
|
588
|
+
|
|
589
|
+
The two declarations are now one contract, and `arrayAdapter` answers the root
|
|
590
|
+
from its own item array rather than delegating — a consumer callback written
|
|
591
|
+
for node ids returns `[]` there, which cannot be told apart from a genuine
|
|
592
|
+
empty answer. A consumer's `getChildren` config is still only ever asked about
|
|
593
|
+
a node id.
|
|
594
|
+
|
|
595
|
+
`arrayAdapter` still exposes no `setChildOrder`, so it places by ordinal rather
|
|
596
|
+
than by anchor. That is unchanged, and it is why the ordinal fallback exists.
|
|
597
|
+
|
|
598
|
+
- 2ea772f: The canvas and the gradient editor now sample one gradient
|
|
599
|
+
|
|
600
|
+
`buildGradientRamp` carried its own interpolation beside
|
|
601
|
+
`sampleGradientStops`, and the two disagreed three ways: the ramp had no guard
|
|
602
|
+
at either end and extrapolated past the first and last stop, the two picked
|
|
603
|
+
opposite sides of a coincident pair, and they parsed color differently — a stop
|
|
604
|
+
written as a CSS named color rendered on the canvas and threw in the editor.
|
|
605
|
+
|
|
606
|
+
`sampleGradientStops` keeps its semantics and is now the only implementation.
|
|
607
|
+
`resolveGradientStops` sorts and parses the list once; `sampleResolvedStops`
|
|
608
|
+
returns the color at `t`. The ramp cache builds its texels through those, so
|
|
609
|
+
there is no interpolation math left in the renderer.
|
|
610
|
+
|
|
611
|
+
Two behavior changes worth naming. `resolveColor` is the surviving parser, so
|
|
612
|
+
gradient stops accept named and functional colors everywhere — but no longer
|
|
613
|
+
hex without a leading `#`, which only the editor path had tolerated and the
|
|
614
|
+
canvas never accepted. And `sampleGradientStops` returns normalized hex at the
|
|
615
|
+
endpoints instead of echoing the raw stop string, so `'red'` comes back as
|
|
616
|
+
`'#ff0000'`.
|
|
617
|
+
|
|
618
|
+
**SVG export:** a conic gradient left the exporter as a dangling `url(#…)` —
|
|
619
|
+
the element already carried the reference, the built-in serializer returned
|
|
620
|
+
nothing, and the registry's `toSvg` slot has no in-repo implementation, so the
|
|
621
|
+
shape disappeared in a browser with no warning at all. Serialization now falls
|
|
622
|
+
through to the same warning the pattern path already emits when nothing can
|
|
623
|
+
produce a paint server. A consumer that registers a `toSvg` for
|
|
624
|
+
`conic-gradient` still serializes and gets no warning.
|
|
625
|
+
|
|
626
|
+
- aba8d91: Answer "can this node be hit" in one place
|
|
627
|
+
|
|
628
|
+
Four tree walks answered it separately — the generic-adapter point pick, the
|
|
629
|
+
one `<SceneCanvas>` installs, `sceneToAdapter`'s area walk, and the live
|
|
630
|
+
marquee/lasso — plus a fifth that shadowed the third. They agreed on every case
|
|
631
|
+
that had a test and disagreed on the rest, three times, silently. `pickWalk`
|
|
632
|
+
now owns every gate; a query supplies only its own shape test and the clip
|
|
633
|
+
predicate for its region.
|
|
634
|
+
|
|
635
|
+
Behavior that changes as a result:
|
|
636
|
+
|
|
637
|
+
- **A node painted at alpha 0 is no longer clickable.** The pick path reads the
|
|
638
|
+
same number the painter does — the view's `alphaFor` times any per-node
|
|
639
|
+
override alpha — so a node faded out of sight stops claiming clicks. The
|
|
640
|
+
floor is exactly zero, so a fade-in is pickable from its first nonzero frame.
|
|
641
|
+
Alpha is per view: dimming a node in one view leaves it pickable in another.
|
|
642
|
+
- **A layer that is not painted no longer claims pointer events.** `drawLayers`
|
|
643
|
+
drops any layer missing from a supplied `layerOrder`, and the chrome hit path
|
|
644
|
+
only consulted `layerVisibility`. Both gates now run through one
|
|
645
|
+
`isLayerPainted`, which is exported.
|
|
646
|
+
- `sceneToAdapter`'s area walk reads override poses and hidden layers, which it
|
|
647
|
+
did not; its default `poseBounds` answers a path pose instead of `NaN`, which
|
|
648
|
+
is what the shadow walk existed to work around.
|
|
649
|
+
- An ancestor clip now rejects an area query that reaches into the clip where
|
|
650
|
+
the node is not, or reaches the node where the clip is not — the two terms
|
|
651
|
+
together, where one alone let false positives through.
|
|
652
|
+
|
|
653
|
+
`useSceneSelectTool` takes `alphaOf` and `layerIsPainted` for the asking view.
|
|
654
|
+
`passesAncestorClips` and its module are gone; `pickWalk`, `scenePickSource`,
|
|
655
|
+
`adapterPickSource` and `ownClipOf` replace them.
|
|
656
|
+
|
|
657
|
+
- 2ea772f: A drag-to-insert reports the bounds it paints
|
|
658
|
+
|
|
659
|
+
The painter, the commit factory and `getGestureBounds()` each sized an
|
|
660
|
+
in-flight insert differently. The reporter read the drag rect alone, so a
|
|
661
|
+
centered Alt-drag reported a half-extent of `d` against a painted circumradius
|
|
662
|
+
of `d√2`, a purely horizontal Alt-drag reported **height 0** for a visibly tall
|
|
663
|
+
star, and a pencil scribble that looped back to its start reported nothing at
|
|
664
|
+
all. The painter and the commit agreed on polygon and star but not on line or
|
|
665
|
+
pencil: the commit posed the drag AABB for a line the painter drew endpoint to
|
|
666
|
+
endpoint, and fell back to the drag rect for a trail under four samples.
|
|
667
|
+
|
|
668
|
+
One function now answers it for all three. The zero-area skip in the painter
|
|
669
|
+
and the reporter tests the resolved extent rather than the raw drag rect, and
|
|
670
|
+
an `InsertNodeFactory` that returns no `pose` falls back to the extent. The
|
|
671
|
+
`bounds` argument handed to a factory is unchanged.
|
|
672
|
+
|
|
673
|
+
- 3386d64: Path command opcodes derive from one table
|
|
674
|
+
|
|
675
|
+
`M`/`L`/`C`/`Q`/`Z` and their coordinate counts were declared five times —
|
|
676
|
+
once in core, once in `@weasel-js/geom`, and three more as `COORD_COUNT`
|
|
677
|
+
literals in the path transform, pose-rotation and pose-descriptor walkers. They
|
|
678
|
+
agreed, and nothing held them to each other: a sixth opcode desynchronizes two
|
|
679
|
+
packages' reading of the same `Uint8Array` with no exception and no type error,
|
|
680
|
+
and every walker misparses the coordinate stream from that command on.
|
|
681
|
+
|
|
682
|
+
`PATH_COMMANDS` in `@weasel-js/geom` is now the table. `PATH_M`…`PATH_Z`,
|
|
683
|
+
`PATH_CMD_LENGTHS` and the new `pathCommandCoordCount` all derive from it, and
|
|
684
|
+
core re-exports them by name, so the opcode constants keep their names, values
|
|
685
|
+
and literal types. The three walkers moved onto `forEachSegment` rather than
|
|
686
|
+
onto the accessor alone — they were duplicating the coordinate-cursor advance
|
|
687
|
+
as well as the length, and the cursor is the half that actually misreads.
|
|
688
|
+
|
|
689
|
+
Eight further files switch on these opcodes with inline literals. Five throw on
|
|
690
|
+
an unknown code; three — the path boolean adapter, the anchor-editing geometry,
|
|
691
|
+
and geom's own boolean adapter — have no `default` arm and would silently stop
|
|
692
|
+
advancing. Left as-is; they need per-command semantics, not one walker.
|
|
693
|
+
|
|
694
|
+
- 68d2651: Pref leaf kinds are declared once, and every renderer is exhaustive
|
|
695
|
+
|
|
696
|
+
`@weasel-js/ui` carried its own copy of the pref-leaf union under a comment
|
|
697
|
+
saying to keep it in sync with core's field-for-field. It had drifted: ui's enum
|
|
698
|
+
leaf had neither `encoding` nor `options[].disabled`, so a dash-array
|
|
699
|
+
preference did not merely fail to select — choosing an option wrote the option
|
|
700
|
+
string over the stored dash array. labkit's two renderers were missing the
|
|
701
|
+
`paint` and `object` kinds outright.
|
|
702
|
+
|
|
703
|
+
ui's schema is now a rename re-export of core's declaration. The public `Pref*`
|
|
704
|
+
names are unchanged, and there is nothing left to keep in sync.
|
|
705
|
+
|
|
706
|
+
More importantly, all four renderer switches ended in `default:`, so adding a
|
|
707
|
+
built-in kind produced no error at any site and simply rendered nothing —
|
|
708
|
+
verified by adding one and typechecking. `ToolPrefLeaf` widens `kind` to
|
|
709
|
+
`string` so app-defined prefs can ride the same tree, which means a `never`
|
|
710
|
+
guard cannot sit on it directly. New from core: `TOOL_PREF_KINDS`, a
|
|
711
|
+
`Record<ToolPrefKind, true>` that a new kind fails to compile against first, and
|
|
712
|
+
`isBuiltinToolPref(leaf)`, which narrows to the closed union so each renderer
|
|
713
|
+
can discriminate and end in a `never`. App-defined kinds take the placeholder
|
|
714
|
+
path as before.
|
|
715
|
+
|
|
716
|
+
Dash-array preferences now select and commit correctly in `PrefsForm`: the enum
|
|
717
|
+
arm threads sibling values, routes through `encoding.read` / `encoding.write`,
|
|
718
|
+
and honors `option.disabled`. `SelectionPanel` already did all of this — it was
|
|
719
|
+
only the forked copy that could not express it.
|
|
720
|
+
|
|
721
|
+
- 3386d64: Dragging out a text box shows a live preview
|
|
722
|
+
|
|
723
|
+
The set of insertable kinds and the `KitInsertShape` union sat on adjacent
|
|
724
|
+
lines with no linkage, and seven more sites restated one list or the other. The
|
|
725
|
+
drift was already live: the text tool binds `actionId: 'insert'` and commits
|
|
726
|
+
through the insert dep, but the runtime set never listed `text`, so a
|
|
727
|
+
drag-to-insert text box had no preview.
|
|
728
|
+
|
|
729
|
+
`SHAPE_KINDS` is now one descriptor table — a row per kind, flagged for whether
|
|
730
|
+
it has a built-in tool and whether it takes an insert preview. Both unions,
|
|
731
|
+
`KIT_SHAPE_KINDS`, `BUNDLE_TOOLS.exhaustive`, the known-builtin-id list and the
|
|
732
|
+
preview gate all derive from it.
|
|
733
|
+
|
|
734
|
+
Two type-surface consequences. `KIT_SHAPE_KINDS` is typed
|
|
735
|
+
`readonly BuiltinShapeToolId[]` rather than a literal tuple — same contents,
|
|
736
|
+
same order, and `(typeof KIT_SHAPE_KINDS)[number]` is unchanged; what goes is
|
|
737
|
+
positional and length typing, which nothing uses. And `OngoingOverlay['shape']`
|
|
738
|
+
gains `'text'`, which is the fix itself: a consumer switching exhaustively over
|
|
739
|
+
it gains a case, handled by the existing box arm.
|
|
740
|
+
|
|
741
|
+
- c6c499d: Text layout is computed once, and the caret reads the layout that was painted
|
|
742
|
+
|
|
743
|
+
The paint, the pose silhouette and the click-to-edit caret each ran their own
|
|
744
|
+
walk. The paint went through a memoized `layoutRuns`; the silhouette re-ran
|
|
745
|
+
`layoutRuns` on every pose change, because it allocates a fresh `ResolvedRun[]`
|
|
746
|
+
per call and the cache keyed on array identity; and the caret summed
|
|
747
|
+
`ctx.measureText` per character, which sees no kerning, reads system fonts
|
|
748
|
+
rather than the registered face, and ignores per-run styling entirely. The
|
|
749
|
+
caret could therefore answer with a different line, and a different glyph, than
|
|
750
|
+
the one under the pointer — masked in practice only because it asked a WebGL
|
|
751
|
+
canvas for a 2D context and got `null`, degrading silently to no caret at all.
|
|
752
|
+
|
|
753
|
+
`cachedLayoutRuns` now lives in `@weasel-js/text` beside the function it caches,
|
|
754
|
+
and all three go through it. It keeps the array-identity `WeakMap` as the
|
|
755
|
+
renderer's zero-cost path and falls through to a bounded LRU keyed on the runs'
|
|
756
|
+
structure, which is what lets a caller that cannot hold a stable array hit it —
|
|
757
|
+
about 230× cheaper than laying out again, at roughly 4× the cost of the
|
|
758
|
+
identity hit. `LaidOutLineBox` carries the caret stops the pen produced, so
|
|
759
|
+
snapping is to the advance cells the glyphs were actually painted in.
|
|
760
|
+
|
|
761
|
+
**Breaking:** `caretIndexAt(ctx, x, y, pose)` is now
|
|
762
|
+
`caretIndexAt(x, y, pose, opts?)` — the `CanvasRenderingContext2D` is gone, and
|
|
763
|
+
an optional `maxWidth` mirrors `textLineBoxes` for nodes the `kit:text` painter
|
|
764
|
+
draws unwrapped. `useSceneTextEdit` no longer acquires a 2D context, so a
|
|
765
|
+
double-click always seeds the caret instead of falling back to editing from
|
|
766
|
+
offset 0. `@weasel-js/text` gains a `./test-seams` entry point exporting
|
|
767
|
+
`_resetLayoutCacheForTests`.
|
|
768
|
+
|
|
769
|
+
- 4f1ef0b: Lay text out from font bytes alone — no baked atlas.
|
|
770
|
+
|
|
771
|
+
`registerFontOutlines` was a paint upgrade for a family that already had an
|
|
772
|
+
MSDF atlas; a family with only font bytes could not resolve, so it rendered
|
|
773
|
+
nothing. It is now a tier in its own right: `OutlineFace` reports `ascender`,
|
|
774
|
+
`advanceOf` and `kernOf` in em units, `resolveFontVariant` resolves an
|
|
775
|
+
outline-only family, and `layoutRuns` reads advances, kerning and the baseline
|
|
776
|
+
through one source the atlas and a parsed face both satisfy. `outlineMinSize`
|
|
777
|
+
does not gate such a family — there is no other tier to prefer.
|
|
778
|
+
|
|
779
|
+
This does not touch metric neutrality where it applies: a family that has an
|
|
780
|
+
atlas still resolves to the atlas, so registering outlines cannot move text
|
|
781
|
+
that was already rendering.
|
|
782
|
+
|
|
783
|
+
Also fixes the outline tier in Node. opentype.js publishes ESM under `module`
|
|
784
|
+
and UMD under `main`; Node takes the UMD build, whose named exports it cannot
|
|
785
|
+
detect, so `parse` was undefined and every face failed to load — silently, via
|
|
786
|
+
the fallback to SDF. A browser bundler reading `module` never saw it.
|
|
787
|
+
|
|
788
|
+
Breaking for a consumer-supplied `OutlineParser`: a face must now report
|
|
789
|
+
metrics as well as geometry.
|
|
790
|
+
|
|
791
|
+
- 0114abf: Add `PaintInput`, a control that edits a whole `FillStyle`.
|
|
792
|
+
|
|
793
|
+
A kind bar over a per-kind body, driven by the paint-kind registry rather than
|
|
794
|
+
a fixed list, so a consumer's registered kind appears in the bar and renders
|
|
795
|
+
that entry's `Editor`. `SelectionPanel`'s `paint` leaf renders it in place of
|
|
796
|
+
the chip that showed a gradient as indeterminate and wrote a solid over it on
|
|
797
|
+
first touch — so the checkerboard now means a mixed selection and nothing else,
|
|
798
|
+
and a gradient stroke is editable rather than merely paintable.
|
|
799
|
+
|
|
800
|
+
Switching kinds keeps a per-kind memory for the control's lifetime, so
|
|
801
|
+
linear -> solid -> linear comes back with its stops instead of the ramp
|
|
802
|
+
`withGradientKind` cannot carry.
|
|
803
|
+
|
|
804
|
+
`PatternPicker` moves from WeaselDraw into `@weasel-js/ui`, which now depends
|
|
805
|
+
on `@weasel-js/svg` for its tile previews.
|
|
806
|
+
|
|
807
|
+
The bar offers **None**: "what kind of paint is this?" takes no-paint as an
|
|
808
|
+
answer. `setFill` and `setStroke` accept `paint: null` to write it — a fill
|
|
809
|
+
becomes `null`, and a stroke goes away entirely rather than keeping a width
|
|
810
|
+
that draws no ink. `PaintKindEntry` gains an optional `icon`, and the five
|
|
811
|
+
built-in kinds carry glyphs so six segments fit a property row.
|
|
812
|
+
|
|
813
|
+
`FILL` and `STROKE` are now peer sections: the `appearance` group goes headless
|
|
814
|
+
and `data.fill` becomes a block leaf. The stroke's paint is no longer paired
|
|
815
|
+
with its width — a whole paint editor cannot share a row with a slider.
|
|
816
|
+
|
|
817
|
+
- 50bc909: `FillStyle` is open: register a sixth paint kind and it renders, converts
|
|
818
|
+
frames and serializes.
|
|
819
|
+
|
|
820
|
+
`registerPaintKind(entry)` returns a disposer and `_resetPaintKindsForTests`
|
|
821
|
+
re-seeds the five built-ins, matching the kit's other module-global
|
|
822
|
+
registries. An entry carries the editor's slots (`label`, `seed`, `colorOf`,
|
|
823
|
+
`Editor`), a render slot, both frame-conversion directions, and an SVG
|
|
824
|
+
`<defs>` slot. `listPaintKinds()` enumerates them, and `asPaint` types a
|
|
825
|
+
consumer's own paint as a `FillStyle` — the union itself stays closed, because
|
|
826
|
+
opening its discriminant would widen every built-in member.
|
|
827
|
+
|
|
828
|
+
Three defects fall out of the same change, each of which a sixth kind hit
|
|
829
|
+
immediately. The renderer's fill dispatch fell off the end of its switch into
|
|
830
|
+
an unguarded cast to the gradient union, so an unknown kind read `stops` off a
|
|
831
|
+
paint with none and threw mid-frame. `fillInPoseFrame` and its inverse returned
|
|
832
|
+
an unknown kind untouched, leaving it painting in screen space on a node that
|
|
833
|
+
moves. `<defs>` emitted nothing for a kind `gradientXml` did not know while
|
|
834
|
+
still writing the `url(#id)` that referenced it.
|
|
835
|
+
|
|
836
|
+
Registering a kind now bumps the node memo generation, so a node painted
|
|
837
|
+
before the registration repaints rather than holding the frame it resolved
|
|
838
|
+
when the kind was unknown.
|
|
839
|
+
|
|
840
|
+
- 6a06f6d: Node paint is an object: `data.fill` is a `FillStyle`, `data.stroke` a `Stroke`
|
|
841
|
+
|
|
842
|
+
Each concept now has exactly one shape. `data.fill` holds a `FillStyle`,
|
|
843
|
+
`data.stroke` a whole `Stroke`, and `null` on either is an explicit "no paint"
|
|
844
|
+
where `undefined` takes the painter's fallback. Two new authoring helpers keep
|
|
845
|
+
hand-written node data short:
|
|
846
|
+
|
|
847
|
+
```ts
|
|
848
|
+
data: { path, fill: solid('#7fb069'), stroke: strokeOf('#1c1c1c', 2) }
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
**Breaking, with no compatibility path.** A document written against the old
|
|
852
|
+
shapes renders wrong rather than failing, which is accepted:
|
|
853
|
+
|
|
854
|
+
- `NodeFill = string | FillStyle` and `NodeStroke = string | Stroke` are gone,
|
|
855
|
+
and so are the string branches of `resolveNodeFill` / `resolveNodeStroke`.
|
|
856
|
+
A node holding `fill: '#f00'` now paints the default grey.
|
|
857
|
+
- `data.strokeWidth` is deleted. A stroke's width is `Stroke.width`.
|
|
858
|
+
- `data.color` — the legacy alias `kit:path` and the rect fallback read — is
|
|
859
|
+
deleted. The fallback painter reads `data.fill` like everything else.
|
|
860
|
+
- `fill: 'none'` is now `fill: null`; `stroke: 'none'` is `stroke: null`.
|
|
861
|
+
- `NodeInkResult` is gone: a painter's `ink` returns `NodeInk` and nothing
|
|
862
|
+
else. A painter returning `{ filled, strokeWidth }` no longer type-checks
|
|
863
|
+
and its reach is read as zero.
|
|
864
|
+
- `@weasel-js/ui` drops `isStrokeObject`, which existed only to discriminate
|
|
865
|
+
the union; `strokeColorOf` and `strokeWithColor` lose their string branches.
|
|
866
|
+
- `@weasel-js/svg`'s `strokeDataFromSvg` returns `Stroke | undefined` instead
|
|
867
|
+
of a `{ stroke, strokeWidth }` pair, and stops flattening a plain solid
|
|
868
|
+
stroke into a color. SVG's `fill="none"` imports as `fill: null`.
|
|
869
|
+
|
|
870
|
+
**A paint's alpha lives in `opacity`, one slot for every paint kind.** That is
|
|
871
|
+
the only slot a gradient or a pattern has, so it is the slot all of them use,
|
|
872
|
+
and the renderer multiplies a hex alpha by it — the two would fight if both
|
|
873
|
+
carried the value. `solid()` therefore moves an alpha channel out of the hex:
|
|
874
|
+
`solid('#ff000080')` is `{ color: '#ff0000', opacity: 0.502 }`.
|
|
875
|
+
|
|
876
|
+
The four setter actions follow: `setFillOpacity` / `setStrokeOpacity` write
|
|
877
|
+
`opacity` rather than splicing hex, so they now work on a gradient fill, which
|
|
878
|
+
they used to leave untouched. `setFill` / `setStroke` given a `color` recolor
|
|
879
|
+
the node's existing paint through the new `paintWithColor`, keeping its opacity
|
|
880
|
+
unless the picked color states an alpha of its own — and `setStroke` keeps the
|
|
881
|
+
stroke's width, cap, join and dash instead of replacing the whole value.
|
|
882
|
+
|
|
883
|
+
New exports: `solid`, `strokeOf`, `paintAlpha`, `paintWithAlpha`,
|
|
884
|
+
`paintWithColor`, `DEFAULT_SHAPE_FILL`.
|
|
885
|
+
|
|
886
|
+
`defaultNodeProperties` moves `data.fill` from a `color` leaf to a `paint` one
|
|
887
|
+
— a color control pointed at a `FillStyle` reads `undefined` off a gradient and
|
|
888
|
+
writes a bare string over it — and the `data.stroke` object leaf drops its
|
|
889
|
+
`fromScalar`, which had nothing left to lift.
|
|
890
|
+
|
|
891
|
+
- a37ee0b: Separate a text node's content from its typography, and draw depth only where a label marks it
|
|
892
|
+
|
|
893
|
+
The text schema put `data.text` in a group named Text, so the section read
|
|
894
|
+
TEXT and the row inside it read Text — one word nested in itself — and the
|
|
895
|
+
style groups below it read as fields of the content string rather than as its
|
|
896
|
+
siblings. Content is its own section now, with the field full-width because
|
|
897
|
+
the section already names it.
|
|
898
|
+
|
|
899
|
+
A group with an empty `name` renders no heading. That already worked for
|
|
900
|
+
sections and is now documented on `ToolPrefGroup`, since it is how a schema
|
|
901
|
+
says "this group organises, it doesn't name": `Character` and `Paragraph`
|
|
902
|
+
carry the labels, and a `Typography` heading over them named nothing new.
|
|
903
|
+
It stays opt-in rather than a rule that rolls up any all-group parent —
|
|
904
|
+
a `Border` over `Top` / `Right` / `Bottom` needs its name.
|
|
905
|
+
|
|
906
|
+
Rows under a suppressed heading no longer indent. Depth drawn without a
|
|
907
|
+
visible parent put `Character` a level deeper than `Content` while being its
|
|
908
|
+
peer, which is the panel's own tree discipline broken by its own hand.
|
|
909
|
+
|
|
910
|
+
- 611b30e: Layers and deps answer for the view they are drawn for
|
|
911
|
+
|
|
912
|
+
Nine lookups closed over the _surface's_ state at construction, so they answered
|
|
913
|
+
for view zero in every view. `<CanvasView>` draws the surface's layer array
|
|
914
|
+
unchanged and only the draw envelope differs, which makes a `draw: (_data, …)`
|
|
915
|
+
a guarantee of answering for the wrong view rather than merely an unused
|
|
916
|
+
argument. A drag in view B ghosted in view A, the marquee painted in the wrong
|
|
917
|
+
view, chrome-caps resolved against the surface's selection, every Cmd+V centered
|
|
918
|
+
on the wrong camera, and Escape in view B cancelled view A.
|
|
919
|
+
|
|
920
|
+
**New on `CanvasViewHelpers`** — `getPreviewSources()`, `getGestureOverlays()`
|
|
921
|
+
and `getIsVisible()`. All three are **required members**: anyone hand-writing a
|
|
922
|
+
`CanvasViewHelpers` (a test double, a wrapper) has to add them.
|
|
923
|
+
`getIsVisible` **moves off `CanvasSurfaceHelpers`**, where it could only ever
|
|
924
|
+
have answered for one view.
|
|
925
|
+
|
|
926
|
+
**New on `GestureSource`** — `previewSources()` and `overlays()`, also required,
|
|
927
|
+
alongside the newly exported `GesturePreviewSource`. `toolPreviewSources(tools)`
|
|
928
|
+
is the tool half.
|
|
929
|
+
|
|
930
|
+
**Layer options changed.** `createPathEditingOverlayLayer` and
|
|
931
|
+
`createSlopsDebugLayer` take `getPose(id, previews)` and have lost their
|
|
932
|
+
`isVisible` / `selectionRef` / `boundsOf` options — those come off the envelope
|
|
933
|
+
now. `usePreviewGhostLayer` has lost `tools`. Both it and
|
|
934
|
+
`useDispatcherOverlayLayer` keep `dispatcher` **only** to subscribe for repaint.
|
|
935
|
+
|
|
936
|
+
**Picking takes a camera.** `pickEvery`, `pickBest` and `makeGetNodeAtPoint`'s
|
|
937
|
+
result accept an optional trailing `PickCamera`. A world point does not carry
|
|
938
|
+
the scale it was produced under and picking has no draw envelope, so the caller
|
|
939
|
+
that produced the point supplies it; omitting it keeps the surface camera.
|
|
940
|
+
|
|
941
|
+
`useHoverTracking` took a `clientToWorld` thunk beside a world-space
|
|
942
|
+
`getNodeAtPoint` — the first resolved the view and the second did not, so hover
|
|
943
|
+
picked at the surface's scale inside a panel. It takes one
|
|
944
|
+
`nodeAtClientPoint(clientX, clientY)` now.
|
|
945
|
+
|
|
946
|
+
Anchor-editing target state stays surface-wide; only the preview resolution on
|
|
947
|
+
that path is per-view.
|
|
948
|
+
|
|
949
|
+
- 9ad8cb2: Picking answers for what was painted
|
|
950
|
+
|
|
951
|
+
Three defects in `<SceneCanvas>`'s hit paths, all one shape — a pick answering
|
|
952
|
+
from something other than what the renderer drew.
|
|
953
|
+
|
|
954
|
+
**Pose overrides were painted through and picked around.** `PoseOverride.pose`
|
|
955
|
+
is documented as replacing the document pose _everywhere the render and
|
|
956
|
+
hit-test paths read one_, and `sceneAdapter.getPose` honored it. But
|
|
957
|
+
`<SceneCanvas>` supplies its own `pickEvery`, which read `node.pose` raw — as
|
|
958
|
+
did the bounds resolver feeding selection chrome and the affordance
|
|
959
|
+
`ChromeState`, and the marquee/lasso scan. A consumer animating nodes through
|
|
960
|
+
overrides painted them at one place and picked them at another. `effectivePose`
|
|
961
|
+
is now the single rule and every one of those reads through it.
|
|
962
|
+
|
|
963
|
+
**A clipped-away child was still clickable.** A container clips its subtree and
|
|
964
|
+
the renderer honors it, so a child outside the clip is not painted.
|
|
965
|
+
`useSelectTool`'s own walk has rejected those since clipping shipped; the walk
|
|
966
|
+
`<SceneCanvas>` installs instead had no clip term at all. The new
|
|
967
|
+
`passesAncestorClips` walks the parent chain per surviving candidate, so a flat
|
|
968
|
+
render-order scan can apply the same test.
|
|
969
|
+
|
|
970
|
+
**The marquee's fast-reject used the unrotated pose box.** A 100×20 rect turned
|
|
971
|
+
45° puts a corner 32 units above that box; a rubber-band over that corner was
|
|
972
|
+
rejected before the rotation-correct silhouette test ran, while a click on the
|
|
973
|
+
same pixel selected the shape.
|
|
974
|
+
|
|
975
|
+
- c1b8511: Repaint the scene-graph side-scroller demo's world from `data.fill`. Its
|
|
976
|
+
tiles, coins, enemies and flagpole still declared `data.color`, the alias
|
|
977
|
+
removed when node paint became an object, so every one of them rendered in
|
|
978
|
+
the default gray — the demo whose whole point is being the visual twin of the
|
|
979
|
+
immediate-mode load test.
|
|
980
|
+
- d793d3c: Flip negates rotation; alignment guides and `gaps` distribute measure ink
|
|
981
|
+
|
|
982
|
+
Three paths read a pose's stored, unrotated box where the rotated extent was
|
|
983
|
+
wanted.
|
|
984
|
+
|
|
985
|
+
`flipPoseAboutBounds` carried rotation through untouched, so a mirrored shape
|
|
986
|
+
came back turned the same way — invisible on a rectangle, whose AABB is
|
|
987
|
+
symmetric under a sign flip, and plainly wrong on an asymmetric one, which
|
|
988
|
+
translated instead of mirroring. It now negates the pose's rotation.
|
|
989
|
+
|
|
990
|
+
`deriveAlignmentGuides` advertised a stationary rotated sibling's lines at its
|
|
991
|
+
stored edges, while the dragged selection matched against them by its ink.
|
|
992
|
+
`RECT_ALIGN_PROJECTION.boundsOf` now returns the rotated AABB and
|
|
993
|
+
`deriveAlignmentGuides` reads its targets through the same projection — a new
|
|
994
|
+
`projection` option defaulting to the rect one, so existing callers get the fix
|
|
995
|
+
without a change.
|
|
996
|
+
|
|
997
|
+
`useDistribute`'s `gaps` mode divided the leftover span by stored widths, so a
|
|
998
|
+
rotated member ended up with a gap short by the difference; `centers` shared the
|
|
999
|
+
line and the blind spot. Both now measure with `visualBoundsViaDescriptor`.
|
|
1000
|
+
`distributeHorizontalAction` / `distributeVerticalAction` also take
|
|
1001
|
+
`params.mode`, so `gaps` is reachable from a binding rather than only from the
|
|
1002
|
+
hook.
|
|
1003
|
+
|
|
1004
|
+
Flip and distribute return different poses than before for rotated shapes.
|
|
1005
|
+
That is the fix, but it is a behavior change for anything depending on the
|
|
1006
|
+
old output.
|
|
1007
|
+
|
|
1008
|
+
- 3386d64: `@weasel-js/core/routing` exports the route-string projection
|
|
1009
|
+
|
|
1010
|
+
Anything rendering a `GestureSpec` as a route string had to re-implement the
|
|
1011
|
+
projection, and the copy in WeaselDraw's registry inspector had drifted three
|
|
1012
|
+
ways: it answered `drop` and `paste` with no gesture name, so every binding of
|
|
1013
|
+
either vanished from the route list; its argument lookup missed a spec field;
|
|
1014
|
+
and it gated targets on a hand-listed set of kinds, dropping them for
|
|
1015
|
+
`pointerDown`, `longPress` and `wheel`.
|
|
1016
|
+
|
|
1017
|
+
New from the routing subpath: `routesForSpec(spec)` — every route string one
|
|
1018
|
+
spec declares — plus `routeGestureForSpecKind(kind)` over the single spec-kind
|
|
1019
|
+
map, and `PREDICATE_TARGET`, which `registry.ts` already exported but the
|
|
1020
|
+
subpath index did not, so consumers reading `RegistryEntry.target` had no way
|
|
1021
|
+
to compare against the sentinel its own docs name.
|
|
1022
|
+
|
|
1023
|
+
- ce2b5c7: Make the inline run grammar a parameter instead of a hardcoded branch.
|
|
1024
|
+
|
|
1025
|
+
`runsToMarkdown` and `markdownToRuns` each had the markdown subset spelled out
|
|
1026
|
+
in their control flow — `***`/`**`/`*` and a two-character escape set — so
|
|
1027
|
+
reading or writing any other spelling meant forking both. They now take a
|
|
1028
|
+
`RunGrammar`: a table of markers pairing a repeated delimiter with the run
|
|
1029
|
+
flags it toggles, defaulting to `MARKDOWN_RUN_GRAMMAR`, which is exactly
|
|
1030
|
+
today's behavior. Escaping follows the grammar's own delimiters.
|
|
1031
|
+
|
|
1032
|
+
Nothing changes for a caller that passes no grammar. `underline` and
|
|
1033
|
+
`strikethrough` still have no markdown spelling and are still dropped by
|
|
1034
|
+
`runsToMarkdown` — a grammar that wants `~~struck~~` now adds one marker
|
|
1035
|
+
rather than editing the parser.
|
|
1036
|
+
|
|
1037
|
+
- 2ea772f: `createSelectionOutlineLayer` and `createSelectionHandlesLayer` now do what the overlay layer does
|
|
1038
|
+
|
|
1039
|
+
`createSelectionOverlayLayer` documents itself as equivalent to stacking the
|
|
1040
|
+
other two, and it was not. It reads `ChromeState` off the draw envelope,
|
|
1041
|
+
resolves the synthetic multi-resize id to the union AABB, honors chrome-caps
|
|
1042
|
+
visibility and suppressed ids, and takes selection and poses from the envelope
|
|
1043
|
+
when they are omitted. The two primitives did none of that: they ignored the
|
|
1044
|
+
draw envelope entirely, required a construction-time `getPose` cascade, and
|
|
1045
|
+
knew nothing about the multi-selection union — so a consumer who stacked them,
|
|
1046
|
+
on the wrapper's own promise, got chrome in the wrong place with no way to
|
|
1047
|
+
tell.
|
|
1048
|
+
|
|
1049
|
+
All three now run one body and differ only in which passes they enable, so the
|
|
1050
|
+
promise holds by construction. `SelectionOutlineLayerOpts` and
|
|
1051
|
+
`SelectionHandlesLayerOpts` become the overlay's option set minus the visuals
|
|
1052
|
+
that don't apply, which makes `getSelection` and `getPose` optional on both and
|
|
1053
|
+
adds `getOutlineIds` and `getSuppressedIds`. Handle visuals are now the named
|
|
1054
|
+
`SelectionHandleStyle`.
|
|
1055
|
+
|
|
1056
|
+
- 3fb3a46: Key `usePublishSelection` on the publish callback, not the context value
|
|
1057
|
+
|
|
1058
|
+
The effect depended on the whole selection-context value, and the provider
|
|
1059
|
+
mints a new value object on every publish. So one publisher publishing refired
|
|
1060
|
+
the effect for every other publisher in scope, each of which republished its
|
|
1061
|
+
own ids — a newer selection got stomped back to an older one, and two
|
|
1062
|
+
publishers holding different ids under one provider never settled at all.
|
|
1063
|
+
|
|
1064
|
+
`publishSelection` is already a stable `useCallback`, so the effect now depends
|
|
1065
|
+
on it directly. No provider change and no API change.
|
|
1066
|
+
|
|
1067
|
+
- 84db1f6: Close four gaps that produced wrong answers with no error
|
|
1068
|
+
|
|
1069
|
+
Three path walkers — `pathToMultiPolygon` in core and in `@weasel-js/geom`, and
|
|
1070
|
+
`enumerateAnchors` behind the bezier-edit overlay — handled M/L/C/Q/Z with no
|
|
1071
|
+
`default:` arm, so a command code they did not know fell out of the switch
|
|
1072
|
+
without advancing the coordinate cursor and every segment after it read the
|
|
1073
|
+
wrong floats. They now throw, matching the six sibling walkers. This is a
|
|
1074
|
+
behavior change for anyone feeding these a path built with an opcode outside
|
|
1075
|
+
`PATH_COMMANDS`: what used to come back subtly wrong now raises.
|
|
1076
|
+
|
|
1077
|
+
A `<CanvasView>` built its affordance hit-test without a device profile, so a
|
|
1078
|
+
nested view resolved fine-pointer radii even under a coarse pointer — 8px grab
|
|
1079
|
+
zones against the 14px chrome the surface paints. It reads the profile
|
|
1080
|
+
`<SceneCanvas>` publishes.
|
|
1081
|
+
|
|
1082
|
+
`moveGestureAdapter`'s `insertNode` took no `index`, and the adapter carried
|
|
1083
|
+
neither `getChildren` nor `setChildOrder`, so the sibling slot a delete op
|
|
1084
|
+
records had nowhere to land: undoing a delete through the move pipeline
|
|
1085
|
+
appended the node to the end of its parent instead of putting it back where it
|
|
1086
|
+
was. All three are there now.
|
|
1087
|
+
|
|
1088
|
+
The dev inspector's gesture panel formatted bindings with a private formatter
|
|
1089
|
+
that reported only modifiers set to `true`. The `ingest` action marks every
|
|
1090
|
+
modifier `'optional'`, so its drop and paste bindings rendered blank and the
|
|
1091
|
+
action was invisible on both gestures. Both of the panel's plain-text
|
|
1092
|
+
formatters now go through the kit's `routesForSpec`.
|
|
1093
|
+
|
|
1094
|
+
- 3386d64: Undoing a multi-node delete or group restores document order
|
|
1095
|
+
|
|
1096
|
+
Restoring by stored index cannot survive replay: history runs a batch's
|
|
1097
|
+
inverses in reverse, while indices captured before the mutation are only
|
|
1098
|
+
correct in ascending order. Deleting `b, c, d` from `[a, b, c, d, e]` and
|
|
1099
|
+
undoing gave `a, b, e, c, d`; Cmd+G on the same three did the same.
|
|
1100
|
+
|
|
1101
|
+
Ops now record a `Slot` — an ordinal plus the id of the following sibling at
|
|
1102
|
+
capture. The anchor is the source of truth whenever it resolves, and it
|
|
1103
|
+
resolves whatever else the batch has already restored. The ordinal remains as
|
|
1104
|
+
the fallback for an adapter that can place by index but cannot enumerate
|
|
1105
|
+
children. `before: null` means "last" and needs no sibling list; an absent
|
|
1106
|
+
`before` means "unobserved", and the two survive `History.serialize` because
|
|
1107
|
+
`undefined` drops out of JSON and `null` does not.
|
|
1108
|
+
|
|
1109
|
+
The ops observe their own slot during `apply()` rather than taking one from the
|
|
1110
|
+
caller, so every existing emitter gets this without a call-site change.
|
|
1111
|
+
`createDeleteOp`'s `index` argument is now a seed that `apply` supersedes; its
|
|
1112
|
+
docstring said it was sufficient on its own, which it never was.
|
|
1113
|
+
|
|
1114
|
+
Adapters without an ordering seam still append, as they did before:
|
|
1115
|
+
`arrayAdapter` has no `setChildOrder`, and the move gesture's adapter has
|
|
1116
|
+
neither that nor an `index` parameter on `insertNode`.
|
|
1117
|
+
|
|
1118
|
+
- 7a746df: A stroke's dash is edited as a style, not as an array
|
|
1119
|
+
|
|
1120
|
+
`Stroke.dash` already rendered, imported and exported; it had no control,
|
|
1121
|
+
because a `number[]` has no leaf kind. It doesn't need one — the thing a person
|
|
1122
|
+
chooses is a style, and the array is how it is stored. The stroke block gains a
|
|
1123
|
+
Solid / Dashed / Dotted / Custom bar under cap, join and align.
|
|
1124
|
+
|
|
1125
|
+
`ToolPrefEnum` gains `encoding`: `read`/`write` between the stored value and
|
|
1126
|
+
the option string, the counterpart of the `unit` a number leaf already has for
|
|
1127
|
+
a value stored in a canonical unit. Both directions are handed the object the
|
|
1128
|
+
leaf is a field of, because a dash pattern is meaningless without the width it
|
|
1129
|
+
scales by — SVG dash lengths are absolute, so a fixed `[6, 3]` is dots on a
|
|
1130
|
+
hairline and a railroad on a 20px stroke. `dashForStrokeStyle` /
|
|
1131
|
+
`strokeDashStyleOf` are the mapping, exported: **dashed is 3× the width on and
|
|
1132
|
+
2× off, dotted 1× on and 2× off**. An array matching neither reads as `custom`,
|
|
1133
|
+
a new `disabled` option — one a control reports but refuses to author, since
|
|
1134
|
+
there is no array behind it. `solid` is stored as no dash at all, and an object
|
|
1135
|
+
leaf's field written as `undefined` is now removed rather than left holding it.
|
|
1136
|
+
|
|
1137
|
+
- 4f19274: Cap, join and align are chosen by glyph, and the stroke block drops its labels
|
|
1138
|
+
|
|
1139
|
+
Nine option glyphs and four category glyphs join the icon set. The option
|
|
1140
|
+
glyphs are filled silhouettes — the glyph is the ink, so a choice reads as a
|
|
1141
|
+
shape rather than as a diagram of one. `align` is a circle zoomed until the
|
|
1142
|
+
ink band's far edge leaves the box: `inner` closes into a disc, `outer` into
|
|
1143
|
+
the box's complement of it, and `center` is the annulus straddling the path,
|
|
1144
|
+
so the three are one band at three offsets. The categories are the bare path
|
|
1145
|
+
each row treats, drawn in the outlined register.
|
|
1146
|
+
|
|
1147
|
+
A schema carries a glyph _id_, not a component: `ToolPrefEnum`'s options gain
|
|
1148
|
+
`icon`, and every leaf gains one for rows whose own label is spent on a
|
|
1149
|
+
`pair`. Core ships no icon set and cannot depend on one, so the field is a
|
|
1150
|
+
plain string; weasel-ui resolves it against `ICON_PATHS` and falls back to
|
|
1151
|
+
`short` where it names no glyph.
|
|
1152
|
+
|
|
1153
|
+
`SelectionPanel` now honours `block` inside an object leaf, not only at the
|
|
1154
|
+
section level. A row whose fields are all `block` drops the 64px label column
|
|
1155
|
+
and spans the block. The default stroke schema uses both: paint and width
|
|
1156
|
+
share one label-less row, and cap/join/align share the next.
|
|
1157
|
+
|
|
1158
|
+
`align`'s options run inner, center, outer — the order the ink moves outward.
|
|
1159
|
+
|
|
1160
|
+
- 94f2446: Add stroke markers — arrowheads and other line terminators as stroke style.
|
|
1161
|
+
|
|
1162
|
+
`markerStart` / `markerMid` / `markerEnd` on `Stroke` take a key resolved
|
|
1163
|
+
through a new registry (`registerMarker`), shipping eight built-in shapes.
|
|
1164
|
+
Unlike SVG, the stroke stops short of a filled head rather than running under
|
|
1165
|
+
it to the tip; the distance is declared per marker, so an open V still reaches
|
|
1166
|
+
the vertex. Round-trips through `@weasel-js/svg` as `marker-*` attributes plus
|
|
1167
|
+
`<marker>` defs.
|
|
1168
|
+
|
|
1169
|
+
- 07fd2de: `setStroke` takes a whole paint, so a gradient or pattern stroke is writable.
|
|
1170
|
+
|
|
1171
|
+
It accepted `{ color }` only, and merged through `paintWithColor`, which
|
|
1172
|
+
supersedes a non-solid paint with a solid one — a gradient stroke was
|
|
1173
|
+
unreachable even though `setStrokeOpacity` could already reach its alpha.
|
|
1174
|
+
`paint` now wins over `color`, a color arriving later in the gesture supersedes
|
|
1175
|
+
an earlier paint, and the stroke's width, cap, join, dash and align survive
|
|
1176
|
+
either. New `strokeWith(paint, width?)` is `strokeOf`'s sibling for a paint
|
|
1177
|
+
that has no color to pass.
|
|
1178
|
+
|
|
1179
|
+
Two fixes alongside it: `setFill` started with no `color` and no `paint` seeded
|
|
1180
|
+
from `DEFAULT_STROKE_COLOR`, painting the selection black where
|
|
1181
|
+
`setFillOpacity` seeds the same slot from `DEFAULT_FILL_COLOR`; and
|
|
1182
|
+
`gradientForBounds`'s doc comment claimed a corner-to-corner linear gradient
|
|
1183
|
+
where the body builds a left-edge-to-right-edge one.
|
|
1184
|
+
|
|
1185
|
+
`@weasel-js/ui` no longer exports `strokeWithColor`. It shared a name with
|
|
1186
|
+
core's and disagreed with it — core's keeps the paint's opacity, ui's dropped
|
|
1187
|
+
it — and nothing imported it.
|
|
1188
|
+
|
|
1189
|
+
- 81213fc: Edit a node's stroke as the union it is
|
|
1190
|
+
|
|
1191
|
+
`data.stroke` holds `string | Stroke`, and the schema described it with a
|
|
1192
|
+
`color` leaf — which reads `undefined` off the object form, shows its own
|
|
1193
|
+
default, and writes a bare hex back over the stroke's width, cap, join and
|
|
1194
|
+
dash on the first edit. The same trap `ToolPrefPaint` was introduced to avoid
|
|
1195
|
+
for `FillStyle`.
|
|
1196
|
+
|
|
1197
|
+
A `stroke` pref kind now describes it, and `defaultNodeProperties` uses it.
|
|
1198
|
+
Its control shows whichever color the value has — the string itself, or a
|
|
1199
|
+
solid paint's color — gives a gradient stroke the indeterminate chip rather
|
|
1200
|
+
than claiming a color it doesn't have, and preserves the form on write.
|
|
1201
|
+
|
|
1202
|
+
`PrefsForm` gained the `stroke` case and the `paint` case it never had; a
|
|
1203
|
+
`paint` leaf used to render as the literal text `(paint: no renderer)`.
|
|
1204
|
+
`solidColorOf`, `strokeColorOf`, `strokeWithColor` and `isStrokeObject` are
|
|
1205
|
+
exported from `@weasel-js/ui` for consumers writing their own property
|
|
1206
|
+
renderers against either union.
|
|
1207
|
+
|
|
1208
|
+
Cap, join and dash are not editable from a panel yet, and `data.strokeWidth`
|
|
1209
|
+
remains its own leaf — see `docs/proposals/2026-08-26-node-stroke-union.md`
|
|
1210
|
+
for why that waits on the SVG mapping.
|
|
1211
|
+
|
|
1212
|
+
- 2f225d7: A thick stroke is clickable across its whole width
|
|
1213
|
+
|
|
1214
|
+
`shapeCoversPoint` grants a grab out to a stroke's outward reach — a full
|
|
1215
|
+
stroke width for an `outer` align — but the AABB pre-filter that runs before it
|
|
1216
|
+
grew only by the pointer slop. So half a thick outer stroke's ink was
|
|
1217
|
+
unclickable: the point was rejected before the refinement that would have
|
|
1218
|
+
claimed it ever ran. `poseContains` carried a comment claiming the pre-filter
|
|
1219
|
+
was at least as generous as the refinement, which it cannot be on its own,
|
|
1220
|
+
since it never sees the stroke. That budget is the caller's, and the comment
|
|
1221
|
+
says so now.
|
|
1222
|
+
|
|
1223
|
+
`ShapeCoversPointOptions.scale` was never passed either, so a stroke width
|
|
1224
|
+
declared in `px` resolved as world units and the reach was wrong at every zoom
|
|
1225
|
+
but 1 — while the caller computed `meanScale(view.scale)` one line above.
|
|
1226
|
+
|
|
1227
|
+
- 68069dc: Right-to-left text lays out in visual order
|
|
1228
|
+
|
|
1229
|
+
`LayoutRunsOpts` takes an optional `bidi` engine. Given one, `layoutRuns`
|
|
1230
|
+
analyses the paragraph, reorders each line after the wrap, and mirrors brackets
|
|
1231
|
+
in right-to-left runs. Given none, nothing changes: text lays out logically,
|
|
1232
|
+
exactly as before.
|
|
1233
|
+
|
|
1234
|
+
`@weasel-js/text` declares the `BidiResolver` interface and does not depend on
|
|
1235
|
+
`@weasel-js/bidi` — the dependency runs the other way from the usual, so a
|
|
1236
|
+
consumer who renders no right-to-left text never installs the Unicode tables,
|
|
1237
|
+
and a different implementation can be substituted. `@weasel-js/bidi` is a
|
|
1238
|
+
devDependency here only, for a test that drives real Hebrew through the real
|
|
1239
|
+
engine; types lining up is not evidence the semantics do.
|
|
1240
|
+
|
|
1241
|
+
`LaidOutCell` gains `advance` and `level`, and **`x` is no longer monotonic
|
|
1242
|
+
across `cells`**. Cells stay in logical order — slot `i` is still character `i`
|
|
1243
|
+
— while their x values follow the reordering. Sort on `x` for visual order, and
|
|
1244
|
+
read a cell's extent as `[x, x + advance)` rather than reaching for the next
|
|
1245
|
+
cell's `x`. Hit-testing was doing exactly that and now sweeps in visual order
|
|
1246
|
+
against each cell's own extent, taking a right-to-left cell's visually-leading
|
|
1247
|
+
half as the character's logical end.
|
|
1248
|
+
|
|
1249
|
+
Kerning is a gap between two adjacent characters, and the wrap measures it
|
|
1250
|
+
logically. Reordering can put a different pair side by side, so the gap taken
|
|
1251
|
+
is the one belonging to whichever of the two is logically second, and none at
|
|
1252
|
+
all across a direction boundary — where the pair never touched in the source.
|
|
1253
|
+
|
|
1254
|
+
Laying out right-to-left text with no engine now warns once, naming the import.
|
|
1255
|
+
The alternative is glyphs silently appearing reversed, which is the one real
|
|
1256
|
+
hazard of making this opt-in.
|
|
1257
|
+
|
|
1258
|
+
- 5d0ff9c: Every code point on a line gets a cell
|
|
1259
|
+
|
|
1260
|
+
`LaidOutLineBox` replaces its `caretXs` / `caretIndices` pair with
|
|
1261
|
+
`cells: LaidOutCell[]` plus a `srcEnd` closing offset. A cell carries
|
|
1262
|
+
`srcIndex`, `srcEnd`, `cp`, `x` and `drawsInk`, so slot `i` is `cells[i]` and
|
|
1263
|
+
a consumer indexing per character no longer has to reconcile a sparse array
|
|
1264
|
+
against the source string.
|
|
1265
|
+
|
|
1266
|
+
The old arrays were documented as non-contiguous, and two causes were real:
|
|
1267
|
+
|
|
1268
|
+
- A code point no tier could serve was dropped outright, taking its caret stop
|
|
1269
|
+
with it. It now occupies a zero-advance cell. This is reachable whenever the
|
|
1270
|
+
dynamic canvas fallback is off — which is the normal configuration for a
|
|
1271
|
+
consumer registering its own outlines, where the outline tier has no rung
|
|
1272
|
+
below it.
|
|
1273
|
+
- A space opening a line — at the start of the text, or after a newline — was
|
|
1274
|
+
discarded. It now keeps its cell and still consumes no width, so a line is
|
|
1275
|
+
addressable per character without gaining an indent. A space that opens a
|
|
1276
|
+
_wrapped_ line was never affected: the wrap leaves it as a trailing cell on
|
|
1277
|
+
the line before.
|
|
1278
|
+
|
|
1279
|
+
Neither changes any geometry: both cells carry zero advance, zero tracking and
|
|
1280
|
+
no kerning, so bounds, line widths and glyph positions are unchanged.
|
|
1281
|
+
|
|
1282
|
+
A newline still has no cell, since it separates cells rather than being one.
|
|
1283
|
+
`srcEnd` is what a blank line carries in its place.
|
|
1284
|
+
|
|
1285
|
+
`drawsInk` is a property of the code point and the face, not of the call that
|
|
1286
|
+
produced it: it does not flip when a dynamic bake lands or the outline
|
|
1287
|
+
threshold is crossed, so the same text reports the same slots every time. A
|
|
1288
|
+
zero-advance combining mark is `true` — it inks without advancing.
|
|
1289
|
+
|
|
1290
|
+
- c1b8511: **Breaking:** paint leaves `TextStyle`. A text node's color and outline are
|
|
1291
|
+
`data.fill` and `data.stroke` — the same two leaves every other node kind
|
|
1292
|
+
paints from — and `TextStyle` holds typography only. `TextStyle.fill` and
|
|
1293
|
+
`TextStyle.stroke` are gone, with no compatibility read: a document that put
|
|
1294
|
+
its color in `style.fill` now renders in the default black rather than
|
|
1295
|
+
erroring, so check documents that predate this.
|
|
1296
|
+
|
|
1297
|
+
This fixes a real asymmetry rather than only moving fields. `data.stroke`
|
|
1298
|
+
already reached text through a fold in the painter, but `data.fill` did not:
|
|
1299
|
+
picking a fill color with a text node selected wrote a field nothing read, so
|
|
1300
|
+
the canvas did not change. `setFill`, `setFillOpacity`, the opacity scrub and
|
|
1301
|
+
the Appearance leaf now all mean the same thing on text as on a rect. The
|
|
1302
|
+
duplicate `data.style.fill` control is gone from the text schema with them.
|
|
1303
|
+
|
|
1304
|
+
`resolveTextStyle(style, paint)` takes the node's paint as a second argument
|
|
1305
|
+
and is what derives the caret and selection colors, so the edit overlay
|
|
1306
|
+
matches the glyphs it sits on; `useTextEdit` gained a `getPaint` option for
|
|
1307
|
+
the same reason, defaulted by `useSceneTextEdit` from `data.fill` /
|
|
1308
|
+
`data.stroke`. `TextPose` gained `fill` / `stroke`, so text drawn through
|
|
1309
|
+
`createTextLayer` is painted rather than black. `SvgTextNode` gained the same
|
|
1310
|
+
two, and SVG import and export carry text paint there instead of inside the
|
|
1311
|
+
style. `StyledRun.fill` and `.stroke` are unchanged and still override the
|
|
1312
|
+
node's per range — which is also where a caller with no node at all, a HUD
|
|
1313
|
+
widget or a debug overlay, now states its color.
|
|
1314
|
+
|
|
1315
|
+
`textCommandFromRuns` is exported from the package root.
|
|
1316
|
+
|
|
1317
|
+
- 546f67d: Draw text from a ring of reused vertex buffers instead of minting a vertex
|
|
1318
|
+
array and two buffers per draw. `drawTextGroup` and `drawTextDecorations` were
|
|
1319
|
+
the last paths still doing what `drawImage` stopped doing; text now costs
|
|
1320
|
+
**3.3 us/command, down from 6.65** at 512 commands a frame on an M2 Max via
|
|
1321
|
+
ANGLE (`tests/perf/transition-matrix.spec.ts`), which puts it level with an
|
|
1322
|
+
image draw. No other command kind moved.
|
|
1323
|
+
|
|
1324
|
+
A text group is as many quads as it has glyphs, so unlike the image ring a
|
|
1325
|
+
slot's buffer grows to the largest run it has seen rather than being fixed at
|
|
1326
|
+
four vertices. The quad index pattern is a pure function of the quad count —
|
|
1327
|
+
the pattern for N quads is a prefix of the pattern for any larger N — so one
|
|
1328
|
+
index buffer serves every slot, grown the same way and written only when it
|
|
1329
|
+
grows.
|
|
1330
|
+
|
|
1331
|
+
- c2ffa49: Alignment can resolve against reading direction
|
|
1332
|
+
|
|
1333
|
+
`align` gains `start` and `end` alongside `left` / `center` / `right`, and
|
|
1334
|
+
`TextStyle` gains `direction: 'ltr' | 'rtl'`. The split is CSS `text-align`'s:
|
|
1335
|
+
the relative pair resolves against the direction, the absolute pair ignores it.
|
|
1336
|
+
`resolveAlign(align, direction)` collapses one to the other and is exported for
|
|
1337
|
+
consumers that need an edge rather than an intent.
|
|
1338
|
+
|
|
1339
|
+
Direction is an input, not something this package discovers. `@weasel-js/text`
|
|
1340
|
+
has no DOM, so a consumer that reads `getComputedStyle(box).direction` passes
|
|
1341
|
+
what it found; nothing here sniffs an environment.
|
|
1342
|
+
|
|
1343
|
+
Defaults are unchanged — `align: 'left'`, `direction: 'ltr'` — so no existing
|
|
1344
|
+
layout moves. Making `start` the default alignment is a separate call.
|
|
1345
|
+
|
|
1346
|
+
`@weasel-js/svg` carries the direction through: `direction` joins the
|
|
1347
|
+
inheritable presentation properties, and `text-anchor` is now written and read
|
|
1348
|
+
against it. Two things were wrong before and are worth naming, because both
|
|
1349
|
+
rendered plausible output:
|
|
1350
|
+
|
|
1351
|
+
- `align: 'start'` serialized to `text-anchor="end"` — the opposite edge — via
|
|
1352
|
+
a mapping that assumed three values and read the fourth as its `else`.
|
|
1353
|
+
- SVG's initial `text-anchor` is `start`, which under `direction="rtl"` is the
|
|
1354
|
+
right edge, while this model's default `align` is `left`. They agree under
|
|
1355
|
+
`ltr` and only there, so an RTL document with no explicit anchor imported as
|
|
1356
|
+
left-aligned.
|
|
1357
|
+
|
|
1358
|
+
This is alignment and round-tripping only. Layout still walks code points in
|
|
1359
|
+
logical order with the pen always increasing: there is no bidi reordering and
|
|
1360
|
+
no shaping, so a Hebrew or Arabic string aligns to the correct edge and still
|
|
1361
|
+
renders in logical order, and Arabic still renders unjoined.
|
|
1362
|
+
|
|
1363
|
+
- 4c097ef: Sit every run on a line on one baseline
|
|
1364
|
+
|
|
1365
|
+
Mixed-size text hung each run off the _line top_ at its own ascent instead of
|
|
1366
|
+
off a shared baseline, so a 16-unit run beside a 40-unit run floated up level
|
|
1367
|
+
with the big run's cap rather than standing on the line with it. Two faces with
|
|
1368
|
+
different ascents at the same size diverged the same way. Baseline alignment is
|
|
1369
|
+
what inline text does everywhere else, and the module header already claimed
|
|
1370
|
+
this behavior — the walk just never implemented it.
|
|
1371
|
+
|
|
1372
|
+
A line now sinks one baseline far enough to clear its tallest run's ascent and
|
|
1373
|
+
places every glyph against it. Glyph quads derive their top from that baseline
|
|
1374
|
+
rather than from the pen's line top, which is the whole of the change:
|
|
1375
|
+
`qy0 = baselineY + (yoffset - metrics.base) * scale`.
|
|
1376
|
+
|
|
1377
|
+
Uniform-size text — nearly all text — is unchanged, since the maximum over one
|
|
1378
|
+
value is that value. Only lines that actually mix sizes or faces move, and they
|
|
1379
|
+
move to where they always should have been.
|
|
1380
|
+
|
|
1381
|
+
The test named "mixed-size runs share a baseline on the same line" asserted only
|
|
1382
|
+
a quad count and passed throughout; it now asserts the baselines.
|
|
1383
|
+
|
|
1384
|
+
- 2b86e00: A text node's style is one value, not ten sibling paths
|
|
1385
|
+
|
|
1386
|
+
`data.style.fontSize`, `.fontWeight`, `.align` and the rest addressed into one
|
|
1387
|
+
`TextStyle` from ten independent leaves, each control writing a field of a
|
|
1388
|
+
value it could only half see. `data.style` is an object leaf now, with
|
|
1389
|
+
Character and Paragraph as groups inside it — groups head their fields and
|
|
1390
|
+
contribute nothing to the path, so a field is still a field of the style and
|
|
1391
|
+
one commit writes the whole thing.
|
|
1392
|
+
|
|
1393
|
+
An object leaf whose fields are entirely grouped no longer prints its own
|
|
1394
|
+
heading, which would stack straight onto the first group's, and a group's
|
|
1395
|
+
fields sit under a rule so the nesting reads. WeaselDraw's inspector descends
|
|
1396
|
+
into an object leaf when listing what a kind exposes — the fields are the
|
|
1397
|
+
editable surface; the leaf is the container.
|
|
1398
|
+
|
|
1399
|
+
`SelectionPanel` has a story now, which is how the two layout defects above
|
|
1400
|
+
were found.
|
|
1401
|
+
|
|
1402
|
+
- d933a89: Superscript, subscript and overline for styled runs
|
|
1403
|
+
|
|
1404
|
+
`StyledRun` gains `script: 'super' | 'sub'` — a raised or lowered baseline and
|
|
1405
|
+
a smaller size together, the pair `<sup>` and `<sub>` imply. It is a preset
|
|
1406
|
+
over two new primitives rather than a mechanism of its own:
|
|
1407
|
+
|
|
1408
|
+
- `baselineShift` — raise (positive) or lower (negative) a run off the line's
|
|
1409
|
+
shared baseline, in ems of the inherited font size.
|
|
1410
|
+
- `fontScale` — a multiplier on the inherited font size, the relative
|
|
1411
|
+
counterpart to `fontSize`. An absolute `fontSize` still wins over it.
|
|
1412
|
+
|
|
1413
|
+
Naming either directly overrides that half of `script` and leaves the other
|
|
1414
|
+
alone. The preset's numbers are exported as `SCRIPT_METRICS` (58.3% size,
|
|
1415
|
+
±33.3% position — Adobe's defaults, so a character panel can show percentages
|
|
1416
|
+
its users already recognize) and are derived, not read from the font: `OS/2`
|
|
1417
|
+
carries real `ySuperscript*` metrics but the baked atlas tier has no slot for
|
|
1418
|
+
them, and metrics that applied on one glyph tier and not the other would
|
|
1419
|
+
reflow text as it crossed the size threshold.
|
|
1420
|
+
|
|
1421
|
+
`resolveRuns` folds all of it into one world-unit `baselineShift` and a final
|
|
1422
|
+
`fontSize`, so layout never learns superscripts exist — it places a run against
|
|
1423
|
+
a baseline and an offset. The shift moves a run's glyphs, its outline geometry
|
|
1424
|
+
and its own decoration rules together, and deliberately does not feed back into
|
|
1425
|
+
the line's baseline or height: a superscript rides the line rather than
|
|
1426
|
+
reflowing it.
|
|
1427
|
+
|
|
1428
|
+
`overline` joins `underline` and `strikethrough` on both `TextStyle` and
|
|
1429
|
+
`StyledRun`, additive over the node style like the other two, and is now
|
|
1430
|
+
available to a custom `RunGrammar` as a `RunFlag`. The default markdown grammar
|
|
1431
|
+
is unchanged — it stays silent on the decorations, as it always has been.
|
|
1432
|
+
|
|
1433
|
+
- 5923c8b: `Animator.tween` no longer fires `onDone` for a tween that was cancelled during
|
|
1434
|
+
its own final `onTick`. The last tick emitted the value and completed in one
|
|
1435
|
+
pass, so a write made from that tick — cancelling the tween — still got the
|
|
1436
|
+
completion callback, against the documented "not called on cancel" contract.
|
|
1437
|
+
- 2ea772f: Undo of a delete restores the subtree; undo of a group restores the slot
|
|
1438
|
+
|
|
1439
|
+
Two ops inverted to something narrower than what they applied, so undo
|
|
1440
|
+
silently lost data.
|
|
1441
|
+
|
|
1442
|
+
`createDeleteOp.invert()` re-inserted a single node while `apply()` called
|
|
1443
|
+
`removeNode`, which cascades the whole subtree. Delete a container with two
|
|
1444
|
+
children, undo, and the container came back with `children: []` while both
|
|
1445
|
+
children were gone. The op now snapshots its descendants preorder through the
|
|
1446
|
+
adapter's optional `getNode` / `getChildren` — the snapshot is written back
|
|
1447
|
+
into `args`, so an op rebuilt from a serialized entry still inverts — and
|
|
1448
|
+
re-inserts each descendant at its captured slot. A flat adapter's `removeNode`
|
|
1449
|
+
does not cascade, so the inverse skips any descendant the adapter still reports
|
|
1450
|
+
as live rather than duplicating it.
|
|
1451
|
+
|
|
1452
|
+
`createReparentOp` carried only the parent ids, so undoing a Cmd+G appended
|
|
1453
|
+
instead of restoring the sibling slot and paint order changed. `ReparentArgs`
|
|
1454
|
+
now carries `fromIndex` / `toIndex` and places through the existing
|
|
1455
|
+
`getChildren` / `setChildOrder` seam that `createReorderOp` already uses —
|
|
1456
|
+
`setParent`'s signature is unchanged. Adapters without that seam no-op as
|
|
1457
|
+
before. `groupAction` captures each member's index before mutating; `move` and
|
|
1458
|
+
`snapToContainer` pass none and are byte-identical.
|
|
1459
|
+
|
|
1460
|
+
`ops/delete.test.ts` stubbed `removeNode` as a one-id delete that did not
|
|
1461
|
+
cascade, which is why nothing caught the first bug. It now runs against a
|
|
1462
|
+
tree-backed fake.
|
|
1463
|
+
|
|
1464
|
+
- 2ea772f: Selection chrome, gesture bounds and SVG export fold rotated ink, not pose boxes
|
|
1465
|
+
|
|
1466
|
+
Every union a user looks at or clicks folded each member's _unrotated_ box.
|
|
1467
|
+
Select two shapes, rotate one, and the multi-selection frame and its handles
|
|
1468
|
+
sat inside the rotated shape's ink — affordances hand `ChromeState.unionBounds`
|
|
1469
|
+
out as the target bounds for paint _and_ hit-test, so the handles were both
|
|
1470
|
+
drawn and grabbable in the wrong place, while `getGestureBounds()` reported the
|
|
1471
|
+
correct larger box.
|
|
1472
|
+
|
|
1473
|
+
`unionAABB` expands each rotated member via `axisAlignedBounds` before folding
|
|
1474
|
+
and is now the one implementation. It lives in `core/geometry/unionBounds.ts`
|
|
1475
|
+
beside the rotation-free `unionBounds`, which stays correct for commit-time
|
|
1476
|
+
actions that write poses back in the unrotated frame; the module says which to
|
|
1477
|
+
reach for. `unionGestureBounds` is **removed** — it was `unionAABB` under
|
|
1478
|
+
another name. Both new functions are exported from the package root.
|
|
1479
|
+
|
|
1480
|
+
Moved onto it: `ChromeState.unionBounds`, the selection overlay's
|
|
1481
|
+
container-to-leaves resolver, the multi-rotate pivot (which put the pivot in
|
|
1482
|
+
the wrong place whenever a member was rotated), and WeaselDraw's export
|
|
1483
|
+
viewBox, which clipped rotated shapes out of the copied SVG.
|
|
1484
|
+
|
|
1485
|
+
- 3fb3a46: Warn in dev when `useAction` finds no `ActionsProvider`
|
|
1486
|
+
|
|
1487
|
+
`useAction` returned early on a null registry, so an action registered above
|
|
1488
|
+
the provider — or with no provider mounted — silently never fired its
|
|
1489
|
+
bindings. It now warns in dev, naming the action id. Runtime behavior in
|
|
1490
|
+
production builds is unchanged.
|
|
1491
|
+
|
|
1492
|
+
- Updated dependencies [3386d64]
|
|
1493
|
+
- Updated dependencies [c6c499d]
|
|
1494
|
+
- Updated dependencies [20097e6]
|
|
1495
|
+
- Updated dependencies [84db1f6]
|
|
1496
|
+
- Updated dependencies [94f2446]
|
|
1497
|
+
- Updated dependencies [68069dc]
|
|
1498
|
+
- Updated dependencies [5d0ff9c]
|
|
1499
|
+
- Updated dependencies [0bb27a5]
|
|
1500
|
+
- Updated dependencies [c2ffa49]
|
|
1501
|
+
- Updated dependencies [4c097ef]
|
|
1502
|
+
- Updated dependencies [d933a89]
|
|
1503
|
+
- @weasel-js/geom@2.0.0-pre.0
|
|
1504
|
+
- @weasel-js/text@2.0.0-pre.0
|
|
1505
|
+
- @weasel-js/gestures@2.0.0-pre.0
|
|
1506
|
+
- @weasel-js/history@2.0.0-pre.0
|
|
1507
|
+
- @weasel-js/modes@2.0.0-pre.0
|
|
1508
|
+
- @weasel-js/paint@2.0.0-pre.0
|
|
1509
|
+
- @weasel-js/font@2.0.0-pre.0
|
|
1510
|
+
|
|
3
1511
|
## 1.2.0
|
|
4
1512
|
|
|
5
1513
|
### Patch Changes
|