@hypen-space/web 0.4.82 → 0.4.83
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/dist/canvas/events.d.ts +30 -2
- package/dist/canvas/events.js +112 -30
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/input.d.ts +15 -2
- package/dist/canvas/input.js +43 -24
- package/dist/canvas/input.js.map +1 -1
- package/dist/canvas/layout.js +479 -96
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +4 -0
- package/dist/canvas/paint.js +141 -29
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/props.d.ts +55 -0
- package/dist/canvas/props.js +121 -0
- package/dist/canvas/props.js.map +1 -0
- package/dist/canvas/renderer.d.ts +13 -0
- package/dist/canvas/renderer.js +109 -11
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +27 -3
- package/dist/canvas/scroll.js +66 -26
- package/dist/canvas/scroll.js.map +1 -1
- package/dist/canvas/selection.js +3 -4
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/utils.d.ts +23 -3
- package/dist/canvas/utils.js +90 -30
- package/dist/canvas/utils.js.map +1 -1
- package/dist/dom/applicators/border.js +9 -0
- package/dist/dom/applicators/border.js.map +1 -1
- package/dist/dom/applicators/font.js +18 -1
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/layout.js +44 -8
- package/dist/dom/applicators/layout.js.map +1 -1
- package/dist/dom/applicators/margin.js +18 -53
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +21 -33
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/applicators/typography.js +13 -1
- package/dist/dom/applicators/typography.js.map +1 -1
- package/dist/dom/components/column.d.ts +11 -2
- package/dist/dom/components/column.js +12 -4
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/image.js +9 -3
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/input.js +11 -0
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/textarea.js +8 -0
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/renderer.d.ts +18 -0
- package/dist/dom/renderer.js +87 -3
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +2 -2
- package/src/canvas/PARITY.md +254 -0
- package/src/canvas/events.ts +113 -32
- package/src/canvas/input.ts +44 -25
- package/src/canvas/layout.ts +492 -91
- package/src/canvas/paint.ts +143 -29
- package/src/canvas/props.ts +117 -0
- package/src/canvas/renderer.ts +132 -11
- package/src/canvas/scroll.ts +62 -29
- package/src/canvas/selection.ts +3 -4
- package/src/canvas/utils.ts +83 -31
- package/src/dom/applicators/border.ts +9 -0
- package/src/dom/applicators/font.ts +16 -1
- package/src/dom/applicators/layout.ts +34 -7
- package/src/dom/applicators/margin.ts +16 -43
- package/src/dom/applicators/padding.ts +19 -27
- package/src/dom/applicators/typography.ts +11 -1
- package/src/dom/components/column.ts +12 -4
- package/src/dom/components/image.ts +8 -3
- package/src/dom/components/input.ts +11 -0
- package/src/dom/components/textarea.ts +8 -0
- package/src/dom/renderer.ts +92 -3
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Canvas Renderer — DOM Parity Gaps
|
|
2
|
+
|
|
3
|
+
Tracked list of gaps between `CanvasRenderer` and `DOMRenderer` when driven by
|
|
4
|
+
the real WASM engine (the social example is the reference workload).
|
|
5
|
+
|
|
6
|
+
The renderer currently handles the basic cases its unit tests cover (simple
|
|
7
|
+
column/row flex, flat prop names), but has not been exercised end-to-end
|
|
8
|
+
against engine output. Recent fixes landed the obvious bridging layer —
|
|
9
|
+
applicator-namespaced props, rem/em length parsing, `scrollable`↔`overflow`,
|
|
10
|
+
`aspectRatio` — and an additional batch (Stack overlay, image intrinsic
|
|
11
|
+
aspect, per-axis scroll, hit-test scroll offset, intrinsic-size flex-shrink)
|
|
12
|
+
landed alongside the `tests/canvas-parity.test.ts` fixture suite. The
|
|
13
|
+
remaining items below still block full parity.
|
|
14
|
+
|
|
15
|
+
## Known gaps
|
|
16
|
+
|
|
17
|
+
### Text
|
|
18
|
+
|
|
19
|
+
- **Line wrapping inside flex rows.** Text that wraps across lines in the
|
|
20
|
+
DOM currently either overflows horizontally or reports the wrong measured
|
|
21
|
+
height back to Taffy. Columns below it then collapse because subsequent
|
|
22
|
+
children layout against stale content height.
|
|
23
|
+
- **Measurement before first layout pass.** Text nodes report width based on
|
|
24
|
+
`ctx.measureText` at the moment the Taffy tree is built; when the Taffy
|
|
25
|
+
WASM layer initialises async, early frames get the JS-flexbox fallback and
|
|
26
|
+
measurements can disagree between the two paths.
|
|
27
|
+
|
|
28
|
+
### Layout
|
|
29
|
+
|
|
30
|
+
- **`Stack` absolute positioning.** Fixed — Stack now uses Taffy Grid with a
|
|
31
|
+
single 1×1 cell and every child placed at `gridColumn: 1 / 2; gridRow: 1
|
|
32
|
+
/ 2`, mirroring the DOM `grid-template-areas: "stack"` strategy.
|
|
33
|
+
`horizontalAlignment` / `verticalAlignment` map to `justifyItems` /
|
|
34
|
+
`alignItems` so the badge can pin to any corner.
|
|
35
|
+
- **`Image` natural-size fallback.** Fixed — when an Image declares only one
|
|
36
|
+
of `width`/`height` and its intrinsic size is known (loaded image, or
|
|
37
|
+
seeded via `setImageNaturalSize` for tests), the other dimension is
|
|
38
|
+
derived from `naturalWidth / naturalHeight` and pinned explicitly so the
|
|
39
|
+
flex parent's default `align-items: stretch` can't override it.
|
|
40
|
+
- **Per-axis scroll.** Fixed — `getScrollAxes(node)` returns `{ x, y }` and
|
|
41
|
+
the wheel/drag/clamp paths in `ScrollManager` honour each axis
|
|
42
|
+
independently. A horizontal strip with `scrollable: "horizontal"` no
|
|
43
|
+
longer accumulates Y scroll range from tall children, and the layout
|
|
44
|
+
builder additionally drops `flex-shrink` to 0 on direct children of a
|
|
45
|
+
main-axis-scrollable parent so items overflow instead of collapsing.
|
|
46
|
+
- **Scrolled hit-testing / event routing.** Fixed — `getScrollAwareBounds`
|
|
47
|
+
no longer double-walks the parent chain to add `contentX/Y` (the layout
|
|
48
|
+
pass already writes absolute coordinates), and only subtracts each
|
|
49
|
+
scrollable ancestor's scroll offset. `getAbsoluteBounds` simplified to
|
|
50
|
+
match.
|
|
51
|
+
|
|
52
|
+
### Painters
|
|
53
|
+
|
|
54
|
+
- **Button transparent background.** Fixed — explicit backgrounds now win
|
|
55
|
+
over the default-blue hover swap. Mentioned here as the canary for
|
|
56
|
+
per-component painters that still assume flat-prop defaults; audit the
|
|
57
|
+
rest of `paint.ts` against engine output.
|
|
58
|
+
- **Icon sizing inside buttons.** Fixed — intrinsic-sized components (icon,
|
|
59
|
+
avatar, badge, switch, checkbox, radio, spinner) now default to
|
|
60
|
+
`flex-shrink: 0`. They keep their default size in tight cross-axis
|
|
61
|
+
containers instead of collapsing to 0×0.
|
|
62
|
+
- **Border-radius clamping.** Fixed — Tailwind's `rounded-full` lands as
|
|
63
|
+
`border-radius: 9999px`. Without clamping, `drawRoundedRect`'s arcs
|
|
64
|
+
wrapped outside the box and a 20×20 blue badge painted as a 470×250
|
|
65
|
+
rectangle, swamping the Stories row. `drawRoundedRect` and
|
|
66
|
+
`isPointInRoundedRect` now clamp `radius` to `min(width, height) / 2`.
|
|
67
|
+
|
|
68
|
+
### Hosting
|
|
69
|
+
|
|
70
|
+
- **Taffy WASM 404 in the social example.** Fixed in
|
|
71
|
+
`examples/social/typescript/web_canvas/serve.ts` — the bundled canvas
|
|
72
|
+
references `taffy_wasm_bg.wasm` at the page origin, so the dev server
|
|
73
|
+
needs to serve it. Without this route the renderer silently falls back
|
|
74
|
+
to the JS flexbox path, which drops most of the parity work above.
|
|
75
|
+
|
|
76
|
+
### Engine prop shape
|
|
77
|
+
|
|
78
|
+
- **Element type case sensitivity.** Fixed — engine emits types
|
|
79
|
+
capitalised (`"Text"`, `"Button"`, `"Input"`, …). The Taffy `buildTree`
|
|
80
|
+
text-leaf check used `node.type === "text"` and missed; every Text
|
|
81
|
+
became an empty container with size 0×0 and downstream siblings landed
|
|
82
|
+
on top of each other (the visible symptom: "1,431 likes" overlapped the
|
|
83
|
+
caption row). The renderer's `clickable`/`focusable` flags had the same
|
|
84
|
+
bug — Buttons weren't flagged clickable, so cursor barely fired.
|
|
85
|
+
All per-type checks now lowercase the type before comparing.
|
|
86
|
+
|
|
87
|
+
### Painters
|
|
88
|
+
|
|
89
|
+
- **Image circular clipping.** Fixed — `paintImage` clips to the rounded
|
|
90
|
+
box before drawing, so `rounded-full` avatars actually paint as circles
|
|
91
|
+
(story rings + post-header avatars). Also added `objectFit: cover`
|
|
92
|
+
semantics — the source is centered and cropped instead of stretched.
|
|
93
|
+
|
|
94
|
+
### Scrolling
|
|
95
|
+
|
|
96
|
+
- **Root inflated by overflowing children.** Fixed — when the root has no
|
|
97
|
+
explicit width/height, `computeLayoutTaffy` now pins it to the canvas
|
|
98
|
+
dimensions. Previously a horizontally-scrollable Stories row with 5+
|
|
99
|
+
w-24 items inflated the entire feed to 607px on a 470px canvas and
|
|
100
|
+
pushed the BottomNav off-screen.
|
|
101
|
+
- **Scroll containers contribute their min-content to parent layout.**
|
|
102
|
+
Fixed — scrollable nodes set `style.overflow = Scroll` on the relevant
|
|
103
|
+
axis. Children still flow at intrinsic positions (so
|
|
104
|
+
`ScrollManager.updateScrollBounds` sees the correct scrollHeight) but
|
|
105
|
+
the container itself stops bubbling its min-content sideways.
|
|
106
|
+
|
|
107
|
+
### Events
|
|
108
|
+
|
|
109
|
+
- **Cursor flash on hover over Buttons.** Fixed — the hit test returns
|
|
110
|
+
the deepest visible node, which for a Button-with-Icon is the Icon
|
|
111
|
+
(not clickable). `events.ts` now lifts the hit to the nearest
|
|
112
|
+
clickable ancestor for hover/cursor and click/mousedown/mouseup
|
|
113
|
+
dispatch, so the cursor stays a pointer over the whole Button surface.
|
|
114
|
+
- **Clicks silently dropped after `mouseup`.** Fixed — `onMouseUp`
|
|
115
|
+
cleared `mouseDownNode = null`, but the browser fires `click` AFTER
|
|
116
|
+
`mouseup`, and `onClick`'s press+release sameness check then always
|
|
117
|
+
failed (`null !== <Button>`). The clear is now done in `onClick`.
|
|
118
|
+
|
|
119
|
+
### Text rendering
|
|
120
|
+
|
|
121
|
+
- **`View all X comments` wraps to 2 lines.** Fixed — pretext returns a
|
|
122
|
+
fractional pixel width (e.g. `141.71875` for "View all 87 comments"),
|
|
123
|
+
Taffy gives the Button exactly that, but at paint time `renderText`
|
|
124
|
+
re-measures with a maxWidth of the integer-rounded box width and
|
|
125
|
+
pretext wraps because `141.71 > 141`. The Taffy measure callback now
|
|
126
|
+
returns `Math.ceil(metrics.width)` so the box reserves the next whole
|
|
127
|
+
pixel up.
|
|
128
|
+
|
|
129
|
+
### Components
|
|
130
|
+
|
|
131
|
+
- **`List` rendered as a row.** Fixed — `List` is the DSL's vertical-
|
|
132
|
+
iteration container (DOM uses `flex-direction: column`). The canvas
|
|
133
|
+
buildTaffyStyle now treats `type === "list"` as Column unless the
|
|
134
|
+
`direction` prop says `"horizontal"`. Notifications stack vertically
|
|
135
|
+
again.
|
|
136
|
+
- **`Grid(...).gridColumns(N)` rendered as 1 column.** Fixed — the
|
|
137
|
+
canvas was reading `gridTemplateColumns`/`columns` only, but the
|
|
138
|
+
engine emits the applicator name (`gridColumns`) on props. Also,
|
|
139
|
+
`parseGridTemplate(N)` now expands a bare number to N equal `1fr`
|
|
140
|
+
tracks (matching `dom/applicators/advanced-layout.ts`).
|
|
141
|
+
- **Grid items with `width: "100%"` collapsed track sizing.** Fixed —
|
|
142
|
+
on a Grid child, `width: "100%"` creates a circular dependency with
|
|
143
|
+
`1fr` track sizing (the cell wants to be `1fr` of the grid; the item
|
|
144
|
+
wants to be `100%` of the cell). Children of a Grid container now
|
|
145
|
+
ignore `width: "100%"` / `height: "100%"` (use `auto`) so Taffy's
|
|
146
|
+
default grid stretch fills the cell.
|
|
147
|
+
- **Grid items with `aspect-ratio: 1` swallowed siblings.** Fixed —
|
|
148
|
+
default `min-width: auto` (= min-content) on grid items collapsed
|
|
149
|
+
tracks. Grid children now default to `min-width: 0; min-height: 0`,
|
|
150
|
+
the standard CSS workaround.
|
|
151
|
+
|
|
152
|
+
### Flex defaults
|
|
153
|
+
|
|
154
|
+
- **`flex: 1` shorthand left basis at `auto`.** Fixed — `flex: 1` in
|
|
155
|
+
CSS expands to `flex: 1 1 0%`, but the canvas was only reading the
|
|
156
|
+
grow value. Without the `0%` basis, an overflowing flex item started
|
|
157
|
+
at its intrinsic content size and Taffy redistributed from there,
|
|
158
|
+
squeezing the BottomNav off-screen on long routes (the home feed
|
|
159
|
+
with N posts; the search grid with 100s of items).
|
|
160
|
+
|
|
161
|
+
### Z-order
|
|
162
|
+
|
|
163
|
+
- **Absolute-positioned overlays painted under in-flow siblings.**
|
|
164
|
+
Fixed — Story declares its close-button header BEFORE the full-screen
|
|
165
|
+
Image; CSS `z-index` puts the header on top, but Canvas paints in
|
|
166
|
+
tree order, so the Image covered the header (and the Hover/click
|
|
167
|
+
for the close button hit the Image instead). `paintNode` now paints
|
|
168
|
+
flow children first, then absolute children on top; `events.ts`
|
|
169
|
+
hit-tests absolutes first (front-to-back). Story's ✕ navigates
|
|
170
|
+
back to `/` again.
|
|
171
|
+
|
|
172
|
+
### Form controls
|
|
173
|
+
|
|
174
|
+
- **Input / Textarea collapse to padding-only height.** Fixed —
|
|
175
|
+
`Input`, `Textarea`, and `Select` paint via the `InputOverlay` HTML
|
|
176
|
+
element, so the canvas layout doesn't see their text content as
|
|
177
|
+
children. The default fall-through gave them `size.auto`, and Taffy
|
|
178
|
+
(border-box by default) collapsed the box to padding only — the
|
|
179
|
+
social Search bar rendered as a 4-tall pill instead of the ~36 the
|
|
180
|
+
DOM produces, with the `Search` placeholder peeking out of a sliver
|
|
181
|
+
that looked nothing like the iOS / Android equivalent. Each control
|
|
182
|
+
now sets `size.height = lineHeight × rows + padding + border` so the
|
|
183
|
+
outer box matches the DOM, with Textarea defaulting to `rows: 3`.
|
|
184
|
+
|
|
185
|
+
### Grid auto rows
|
|
186
|
+
|
|
187
|
+
- **Aspect-ratio image grid collapses to gap-tall rows.** Fixed —
|
|
188
|
+
Taffy's grid auto-row sizing queries each leaf's max-content height
|
|
189
|
+
before column widths are resolved. For an Image leaf with no fixed
|
|
190
|
+
height, max-content = 0, so a `Grid.gridColumns(3)` of `aspect-square`
|
|
191
|
+
images sized the implicit rows to gap-only and items overlapped by
|
|
192
|
+
~150px each (the Search route's explore grid was a single squashed
|
|
193
|
+
band of every image). `computeLayout` now runs a second Taffy pass
|
|
194
|
+
when `annotateCollapsedAspectGrids` finds a Grid where the next-row
|
|
195
|
+
child sits closer than `firstChild.width / aspectRatio` — the second
|
|
196
|
+
pass reads a private `__autoRowsPx` hint set on the grid (column
|
|
197
|
+
width / aspectRatio) and pins implicit rows to that height. Two-pass
|
|
198
|
+
cost is bounded: only fires when an aspect-ratio overflow is observed,
|
|
199
|
+
not on every layout.
|
|
200
|
+
|
|
201
|
+
### HiDPI
|
|
202
|
+
|
|
203
|
+
- **Hit tests offset by `dpr` on Mac retina screens.** Fixed —
|
|
204
|
+
`events.ts` `getCanvasCoordinates` (and the same helpers in `scroll.ts`
|
|
205
|
+
/ `selection.ts` / `input.ts`) used to multiply client coords by
|
|
206
|
+
`canvas.width / rect.width`, which equals `devicePixelRatio` because
|
|
207
|
+
the renderer scales the ctx by dpr at setup. Layout (and therefore
|
|
208
|
+
the hit-test bounds) live in CSS pixels — multiplying again landed
|
|
209
|
+
every click at 2× the bounds. Visible symptoms on a Mac (dpr=2):
|
|
210
|
+
the cursor turned to a pointer ABOVE the actual story avatar instead
|
|
211
|
+
of on it, and bottom-nav clicks/hovers fell off the bottom of the
|
|
212
|
+
canvas entirely (y=850 logical → y=1700 canvas-pixel, past the 900
|
|
213
|
+
canvas height). Drag-scroll deltas and Input/Textarea overlay
|
|
214
|
+
positioning had the same family of bug. All four now use logical
|
|
215
|
+
CSS pixels directly.
|
|
216
|
+
|
|
217
|
+
### Action wire format
|
|
218
|
+
|
|
219
|
+
- **`@router.push` dispatched verbatim.** Fixed — DOM strips the `@`
|
|
220
|
+
prefix and the `actions.` namespace before sending to the engine,
|
|
221
|
+
but the canvas was sending `@router.push` literal. The engine
|
|
222
|
+
silently dropped it, so every nav click was a no-op. `props.ts`
|
|
223
|
+
`resolveEventAction` now mirrors `dom/applicators/events.ts`
|
|
224
|
+
`extractActionDetails`.
|
|
225
|
+
|
|
226
|
+
### Engine prop shape (continued)
|
|
227
|
+
|
|
228
|
+
- **Click was always `null !== <Button>`.** Fixed — `onMouseUp`
|
|
229
|
+
cleared `this.mouseDownNode = null`, but the browser fires `click`
|
|
230
|
+
AFTER `mouseup`. The `node === mouseDownNode` press+release check
|
|
231
|
+
in `onClick` always saw `null`. The clear is now done at the end
|
|
232
|
+
of `onClick` instead.
|
|
233
|
+
|
|
234
|
+
## Not yet assessed
|
|
235
|
+
|
|
236
|
+
- Input / Textarea focus + caret positioning against the InputOverlay DOM
|
|
237
|
+
inputs.
|
|
238
|
+
- Select / Switch / Slider under engine-driven props.
|
|
239
|
+
- Router attach/detach patches (subtree caching) — the canvas renderer
|
|
240
|
+
implements `detach` / `attach` but hasn't been tested against the
|
|
241
|
+
ManagedRouter persist-cache path.
|
|
242
|
+
|
|
243
|
+
## How to make progress
|
|
244
|
+
|
|
245
|
+
The shape of each of these is the same: write a small fixture in
|
|
246
|
+
`tests/canvas-*.test.ts` that reproduces the engine-output shape (use the
|
|
247
|
+
patches captured in `ws://localhost:3000` as the input), assert the expected
|
|
248
|
+
layout / paint output, then fix the canvas code until the test passes. The
|
|
249
|
+
screenshot-driven loop we've been in is too expensive; the real investment
|
|
250
|
+
is in fixture tests that pin behaviour.
|
|
251
|
+
|
|
252
|
+
The social example under `examples/social/typescript/web_canvas/` is the
|
|
253
|
+
end-to-end integration check, not a regression target — carry a visible
|
|
254
|
+
"partial" banner until parity is reached.
|
package/src/canvas/events.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import type { VirtualNode, Point } from "./types.js";
|
|
8
8
|
import { isPointInRoundedRect } from "./utils.js";
|
|
9
9
|
import { getScrollAwareBounds } from "./scroll.js";
|
|
10
|
+
import { resolveEventAction } from "./props.js";
|
|
10
11
|
|
|
11
12
|
// Interface for the engine that CanvasEventManager needs
|
|
12
13
|
interface IEngine {
|
|
@@ -23,6 +24,9 @@ export class CanvasEventManager {
|
|
|
23
24
|
private hoveredNode: VirtualNode | null = null;
|
|
24
25
|
private focusedNode: VirtualNode | null = null;
|
|
25
26
|
private mouseDownNode: VirtualNode | null = null;
|
|
27
|
+
private focusChangeHandler:
|
|
28
|
+
| ((next: VirtualNode | null, prev: VirtualNode | null) => void)
|
|
29
|
+
| null = null;
|
|
26
30
|
|
|
27
31
|
// Bound handler references for cleanup
|
|
28
32
|
private boundOnMouseMove!: (e: MouseEvent) => void;
|
|
@@ -47,6 +51,19 @@ export class CanvasEventManager {
|
|
|
47
51
|
this.rootNode = node;
|
|
48
52
|
}
|
|
49
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe to focus changes. Used by the renderer to mount the
|
|
56
|
+
* `InputOverlay` HTML element when an Input/Textarea gains focus and
|
|
57
|
+
* unmount it when focus moves away. Without this hookup the canvas
|
|
58
|
+
* Input painted as a pretty pill but a click on it did nothing — the
|
|
59
|
+
* overlay was constructed but never invoked.
|
|
60
|
+
*/
|
|
61
|
+
setFocusChangeHandler(
|
|
62
|
+
fn: ((next: VirtualNode | null, prev: VirtualNode | null) => void) | null,
|
|
63
|
+
): void {
|
|
64
|
+
this.focusChangeHandler = fn;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
/**
|
|
51
68
|
* Setup canvas event listeners
|
|
52
69
|
*/
|
|
@@ -71,16 +88,20 @@ export class CanvasEventManager {
|
|
|
71
88
|
}
|
|
72
89
|
|
|
73
90
|
/**
|
|
74
|
-
* Get canvas coordinates from mouse event
|
|
91
|
+
* Get canvas coordinates from mouse event.
|
|
92
|
+
*
|
|
93
|
+
* Returns LOGICAL pixels (CSS pixels), matching the layout coordinate
|
|
94
|
+
* space. Layout is computed in logical units because the renderer calls
|
|
95
|
+
* `ctx.scale(dpr, dpr)` once at setup; do NOT multiply by `canvas.width /
|
|
96
|
+
* rect.width` here — that would land hit tests in canvas-pixel space (2x
|
|
97
|
+
* on a HiDPI Mac), with the practical effect that the cursor changes to a
|
|
98
|
+
* pointer above the actual element instead of on it.
|
|
75
99
|
*/
|
|
76
100
|
private getCanvasCoordinates(e: MouseEvent): Point {
|
|
77
101
|
const rect = this.canvas.getBoundingClientRect();
|
|
78
|
-
const scaleX = this.canvas.width / rect.width;
|
|
79
|
-
const scaleY = this.canvas.height / rect.height;
|
|
80
|
-
|
|
81
102
|
return {
|
|
82
|
-
x:
|
|
83
|
-
y:
|
|
103
|
+
x: e.clientX - rect.left,
|
|
104
|
+
y: e.clientY - rect.top,
|
|
84
105
|
};
|
|
85
106
|
}
|
|
86
107
|
|
|
@@ -93,7 +114,10 @@ export class CanvasEventManager {
|
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
/**
|
|
96
|
-
* Recursively test node and children
|
|
117
|
+
* Recursively test node and children. Absolute-positioned siblings are
|
|
118
|
+
* checked first because they paint on top (see `paint.ts` overlay
|
|
119
|
+
* ordering) — without this, a click on the Story's close-button overlay
|
|
120
|
+
* lands on the underlying Image instead.
|
|
97
121
|
*/
|
|
98
122
|
private hitTestNode(node: VirtualNode, point: Point): VirtualNode | null {
|
|
99
123
|
if (!node.visible || !node.layout) return null;
|
|
@@ -101,9 +125,17 @@ export class CanvasEventManager {
|
|
|
101
125
|
const bounds = getScrollAwareBounds(node);
|
|
102
126
|
if (!bounds) return null;
|
|
103
127
|
|
|
104
|
-
//
|
|
128
|
+
// Front-to-back order: absolute overlays (newest in paint stack)
|
|
129
|
+
// first, then flow children in reverse paint order.
|
|
130
|
+
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
131
|
+
const child = node.children[i];
|
|
132
|
+
if (child.props.position !== "absolute") continue;
|
|
133
|
+
const hit = this.hitTestNode(child, point);
|
|
134
|
+
if (hit) return hit;
|
|
135
|
+
}
|
|
105
136
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
106
137
|
const child = node.children[i];
|
|
138
|
+
if (child.props.position === "absolute") continue;
|
|
107
139
|
const hit = this.hitTestNode(child, point);
|
|
108
140
|
if (hit) return hit;
|
|
109
141
|
}
|
|
@@ -117,12 +149,31 @@ export class CanvasEventManager {
|
|
|
117
149
|
return null;
|
|
118
150
|
}
|
|
119
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Walk up from a node to find the nearest clickable ancestor (or the node
|
|
154
|
+
* itself if it's clickable). Returns null if none. Used so that hovering
|
|
155
|
+
* a non-clickable child of a Button (e.g. the Icon inside a transparent
|
|
156
|
+
* nav button) still resolves to the Button for cursor + click purposes.
|
|
157
|
+
*/
|
|
158
|
+
private findClickableAncestor(node: VirtualNode | null): VirtualNode | null {
|
|
159
|
+
let current = node;
|
|
160
|
+
while (current) {
|
|
161
|
+
if (current.clickable) return current;
|
|
162
|
+
current = current.parent;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
120
167
|
/**
|
|
121
168
|
* Handle mouse move
|
|
122
169
|
*/
|
|
123
170
|
private onMouseMove(e: MouseEvent): void {
|
|
124
171
|
const point = this.getCanvasCoordinates(e);
|
|
125
|
-
const
|
|
172
|
+
const hit = this.hitTest(point);
|
|
173
|
+
// Resolve to the clickable ancestor so the cursor stays a pointer over
|
|
174
|
+
// the whole Button surface (not flashing back to default whenever the
|
|
175
|
+
// mouse passes over the Button's non-clickable Icon child).
|
|
176
|
+
const node = this.findClickableAncestor(hit) ?? hit;
|
|
126
177
|
|
|
127
178
|
// Update hover state
|
|
128
179
|
if (node !== this.hoveredNode) {
|
|
@@ -152,7 +203,10 @@ export class CanvasEventManager {
|
|
|
152
203
|
*/
|
|
153
204
|
private onMouseDown(e: MouseEvent): void {
|
|
154
205
|
const point = this.getCanvasCoordinates(e);
|
|
155
|
-
|
|
206
|
+
// Same lift-to-clickable as hover so a click on a Button's Icon child
|
|
207
|
+
// dispatches against the Button (where `onClick` actually lives).
|
|
208
|
+
const hit = this.hitTest(point);
|
|
209
|
+
const node = this.findClickableAncestor(hit) ?? hit;
|
|
156
210
|
|
|
157
211
|
this.mouseDownNode = node;
|
|
158
212
|
|
|
@@ -177,7 +231,8 @@ export class CanvasEventManager {
|
|
|
177
231
|
*/
|
|
178
232
|
private onMouseUp(e: MouseEvent): void {
|
|
179
233
|
const point = this.getCanvasCoordinates(e);
|
|
180
|
-
const
|
|
234
|
+
const hit = this.hitTest(point);
|
|
235
|
+
const node = this.findClickableAncestor(hit) ?? hit;
|
|
181
236
|
|
|
182
237
|
if (node && node.clickable) {
|
|
183
238
|
this.dispatchNodeEvent(node, "mouseup", {
|
|
@@ -187,7 +242,11 @@ export class CanvasEventManager {
|
|
|
187
242
|
});
|
|
188
243
|
}
|
|
189
244
|
|
|
190
|
-
this.mouseDownNode
|
|
245
|
+
// NOTE: do NOT clear `this.mouseDownNode` here — the browser fires
|
|
246
|
+
// `click` after `mouseup`, and onClick uses mouseDownNode to verify
|
|
247
|
+
// press+release happened on the same target. Clearing here meant the
|
|
248
|
+
// click handler always saw `null` and silently dropped every click.
|
|
249
|
+
// The click handler clears mouseDownNode itself.
|
|
191
250
|
}
|
|
192
251
|
|
|
193
252
|
/**
|
|
@@ -195,7 +254,8 @@ export class CanvasEventManager {
|
|
|
195
254
|
*/
|
|
196
255
|
private onClick(e: MouseEvent): void {
|
|
197
256
|
const point = this.getCanvasCoordinates(e);
|
|
198
|
-
const
|
|
257
|
+
const hit = this.hitTest(point);
|
|
258
|
+
const node = this.findClickableAncestor(hit) ?? hit;
|
|
199
259
|
|
|
200
260
|
if (node && node.clickable && node === this.mouseDownNode) {
|
|
201
261
|
this.dispatchNodeEvent(node, "click", {
|
|
@@ -204,6 +264,7 @@ export class CanvasEventManager {
|
|
|
204
264
|
clientY: e.clientY,
|
|
205
265
|
});
|
|
206
266
|
}
|
|
267
|
+
this.mouseDownNode = null;
|
|
207
268
|
}
|
|
208
269
|
|
|
209
270
|
/**
|
|
@@ -276,9 +337,10 @@ export class CanvasEventManager {
|
|
|
276
337
|
private setFocus(node: VirtualNode | null): void {
|
|
277
338
|
if (node === this.focusedNode) return;
|
|
278
339
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
340
|
+
const prev = this.focusedNode;
|
|
341
|
+
if (prev) {
|
|
342
|
+
prev.focused = false;
|
|
343
|
+
this.dispatchNodeEvent(prev, "blur", {});
|
|
282
344
|
}
|
|
283
345
|
|
|
284
346
|
this.focusedNode = node;
|
|
@@ -288,9 +350,16 @@ export class CanvasEventManager {
|
|
|
288
350
|
this.dispatchNodeEvent(node, "focus", {});
|
|
289
351
|
}
|
|
290
352
|
|
|
353
|
+
if (this.focusChangeHandler) this.focusChangeHandler(node, prev);
|
|
354
|
+
|
|
291
355
|
this.requestRedraw();
|
|
292
356
|
}
|
|
293
357
|
|
|
358
|
+
/** Public for the renderer's overlay-blur path. */
|
|
359
|
+
clearFocus(): void {
|
|
360
|
+
this.setFocus(null);
|
|
361
|
+
}
|
|
362
|
+
|
|
294
363
|
/**
|
|
295
364
|
* Update cursor based on node
|
|
296
365
|
*/
|
|
@@ -300,7 +369,13 @@ export class CanvasEventManager {
|
|
|
300
369
|
return;
|
|
301
370
|
}
|
|
302
371
|
|
|
303
|
-
|
|
372
|
+
let cursor: string | undefined = node.props.cursor;
|
|
373
|
+
if (!cursor) {
|
|
374
|
+
const t = node.type.toLowerCase();
|
|
375
|
+
if (t === "input" || t === "textarea") cursor = "text";
|
|
376
|
+
else if (node.clickable) cursor = "pointer";
|
|
377
|
+
else cursor = "default";
|
|
378
|
+
}
|
|
304
379
|
this.canvas.style.cursor = cursor;
|
|
305
380
|
}
|
|
306
381
|
|
|
@@ -308,23 +383,29 @@ export class CanvasEventManager {
|
|
|
308
383
|
* Dispatch event to engine
|
|
309
384
|
*/
|
|
310
385
|
private dispatchNodeEvent(node: VirtualNode, eventType: string, data: any): void {
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
//
|
|
315
|
-
|
|
316
|
-
|
|
386
|
+
// Engine emits event applicators in camelCase (`onClick`, `onChange`). The
|
|
387
|
+
// older flat form `onclick` is still accepted. After prop normalisation the
|
|
388
|
+
// value is either a string (action name) or an object carrying an action
|
|
389
|
+
// name at `"0"` plus an auxiliary payload (e.g. `{ "0": "@router.push",
|
|
390
|
+
// to: "/notifications" }` for `@router.push(to: "/notifications")`).
|
|
391
|
+
const camel = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`;
|
|
392
|
+
let spec: unknown = node.props[camel] ?? node.props[`on${eventType}`] ?? node.props[eventType];
|
|
393
|
+
|
|
394
|
+
// Actionable components fall back to the bare `action` prop on click.
|
|
395
|
+
if (spec == null && eventType === "click") {
|
|
396
|
+
spec = node.props.action;
|
|
317
397
|
}
|
|
318
398
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
399
|
+
const resolved = resolveEventAction(spec);
|
|
400
|
+
if (!resolved) return;
|
|
401
|
+
|
|
402
|
+
this.engine.dispatchAction(resolved.actionName, {
|
|
403
|
+
type: eventType,
|
|
404
|
+
nodeId: node.id,
|
|
405
|
+
timestamp: Date.now(),
|
|
406
|
+
...resolved.payload,
|
|
407
|
+
...data,
|
|
408
|
+
});
|
|
328
409
|
}
|
|
329
410
|
|
|
330
411
|
/**
|
package/src/canvas/input.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { VirtualNode, Rectangle } from "./types.js";
|
|
8
|
-
import { getAbsoluteBounds } from "./utils.js";
|
|
8
|
+
import { getAbsoluteBounds, cssLengthToPx } from "./utils.js";
|
|
9
9
|
|
|
10
10
|
/** Minimal engine interface for dispatching bind actions */
|
|
11
11
|
interface BindEngine {
|
|
@@ -28,7 +28,12 @@ export class InputOverlay {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* Show input overlay for a node
|
|
31
|
+
* Show input overlay for a node.
|
|
32
|
+
*
|
|
33
|
+
* Type comparison is case-insensitive — the engine emits `"Input"` /
|
|
34
|
+
* `"Textarea"`, but the overlay branch was looking for lowercase
|
|
35
|
+
* `"textarea"` and always created an `<input>` (so multi-line typing
|
|
36
|
+
* couldn't enter newlines and the visible caret was the wrong height).
|
|
32
37
|
*/
|
|
33
38
|
showInput(
|
|
34
39
|
node: VirtualNode,
|
|
@@ -48,7 +53,7 @@ export class InputOverlay {
|
|
|
48
53
|
const bounds = getAbsoluteBounds(node);
|
|
49
54
|
if (!bounds) return;
|
|
50
55
|
|
|
51
|
-
const isMultiline = node.type === "textarea";
|
|
56
|
+
const isMultiline = node.type.toLowerCase() === "textarea";
|
|
52
57
|
this.overlay = isMultiline
|
|
53
58
|
? document.createElement("textarea")
|
|
54
59
|
: document.createElement("input");
|
|
@@ -66,8 +71,10 @@ export class InputOverlay {
|
|
|
66
71
|
this.overlay.addEventListener("blur", this.onBlur.bind(this));
|
|
67
72
|
this.overlay.addEventListener("keydown", this.onKeyDown.bind(this) as EventListener);
|
|
68
73
|
|
|
69
|
-
//
|
|
70
|
-
|
|
74
|
+
// Mount on document.body so absolute positioning is relative to the
|
|
75
|
+
// viewport (`canvasBounds.left/top` are page coords too) — no
|
|
76
|
+
// dependency on the canvas's wrapper having `position: relative`.
|
|
77
|
+
document.body.appendChild(this.overlay);
|
|
71
78
|
this.overlay.focus();
|
|
72
79
|
|
|
73
80
|
this.focusedNode = node;
|
|
@@ -114,24 +121,35 @@ export class InputOverlay {
|
|
|
114
121
|
// Position
|
|
115
122
|
this.positionOverlay(bounds, canvasBounds);
|
|
116
123
|
|
|
117
|
-
// Font and text styling
|
|
118
|
-
|
|
124
|
+
// Font and text styling — go through `cssLengthToPx` so values that
|
|
125
|
+
// arrive in `rem` ("0.875rem" for `text-sm`) round to real pixels
|
|
126
|
+
// instead of `parseFloat`'s bare 0.875 (which made the caret disappear).
|
|
127
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
119
128
|
const fontWeight = props.fontWeight || "normal";
|
|
120
129
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
121
130
|
const color = props.color || "#000000";
|
|
122
131
|
|
|
132
|
+
const padTop = cssLengthToPx(props.paddingTop ?? props.padding) ?? 0;
|
|
133
|
+
const padRight = cssLengthToPx(props.paddingRight ?? props.padding) ?? 0;
|
|
134
|
+
const padBottom = cssLengthToPx(props.paddingBottom ?? props.padding) ?? 0;
|
|
135
|
+
const padLeft = cssLengthToPx(props.paddingLeft ?? props.padding) ?? 0;
|
|
136
|
+
const borderRadius = cssLengthToPx(props.borderRadius) ?? 4;
|
|
137
|
+
|
|
123
138
|
Object.assign(this.overlay.style, {
|
|
124
139
|
fontSize: `${fontSize}px`,
|
|
125
|
-
fontWeight: fontWeight,
|
|
140
|
+
fontWeight: String(fontWeight),
|
|
126
141
|
fontFamily: fontFamily,
|
|
127
142
|
color: color,
|
|
128
143
|
border: "none",
|
|
129
|
-
outline: "
|
|
144
|
+
outline: "none",
|
|
130
145
|
backgroundColor: props.backgroundColor || "#ffffff",
|
|
131
|
-
padding: `${
|
|
132
|
-
borderRadius: `${
|
|
146
|
+
padding: `${padTop}px ${padRight}px ${padBottom}px ${padLeft}px`,
|
|
147
|
+
borderRadius: `${borderRadius}px`,
|
|
133
148
|
boxSizing: "border-box",
|
|
134
149
|
resize: "none",
|
|
150
|
+
// Keep the overlay above the canvas; the Bun-served stage has no
|
|
151
|
+
// stacking context of its own so a plain z-index works.
|
|
152
|
+
zIndex: "1000",
|
|
135
153
|
});
|
|
136
154
|
|
|
137
155
|
// Placeholder
|
|
@@ -146,27 +164,28 @@ export class InputOverlay {
|
|
|
146
164
|
}
|
|
147
165
|
|
|
148
166
|
/**
|
|
149
|
-
* Position overlay over canvas node
|
|
167
|
+
* Position overlay over canvas node.
|
|
168
|
+
*
|
|
169
|
+
* `bounds` is in canvas-local CSS pixels (the layout coordinate space
|
|
170
|
+
* the renderer paints into after `ctx.scale(dpr, dpr)`). The overlay is
|
|
171
|
+
* mounted on `document.body`, so we add `canvasBounds.left/top`
|
|
172
|
+
* (page coords from `getBoundingClientRect`) to land at the right
|
|
173
|
+
* absolute position regardless of where the canvas sits on the page.
|
|
174
|
+
* Adding `window.scrollX/Y` keeps the overlay pinned to the canvas
|
|
175
|
+
* even when the page scrolls between focus and reposition.
|
|
150
176
|
*/
|
|
151
177
|
private positionOverlay(bounds: Rectangle, canvasBounds: DOMRect): void {
|
|
152
178
|
if (!this.overlay) return;
|
|
153
179
|
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
const scaleX = canvasBounds.width / canvas.width;
|
|
157
|
-
const scaleY = canvasBounds.height / canvas.height;
|
|
158
|
-
|
|
159
|
-
const left = bounds.x * scaleX;
|
|
160
|
-
const top = bounds.y * scaleY;
|
|
161
|
-
const width = bounds.width * scaleX;
|
|
162
|
-
const height = bounds.height * scaleY;
|
|
180
|
+
const sx = typeof window !== "undefined" ? window.scrollX : 0;
|
|
181
|
+
const sy = typeof window !== "undefined" ? window.scrollY : 0;
|
|
163
182
|
|
|
164
183
|
Object.assign(this.overlay.style, {
|
|
165
184
|
position: "absolute",
|
|
166
|
-
left: `${left}px`,
|
|
167
|
-
top: `${top}px`,
|
|
168
|
-
width: `${width}px`,
|
|
169
|
-
height: `${height}px`,
|
|
185
|
+
left: `${canvasBounds.left + sx + bounds.x}px`,
|
|
186
|
+
top: `${canvasBounds.top + sy + bounds.y}px`,
|
|
187
|
+
width: `${bounds.width}px`,
|
|
188
|
+
height: `${bounds.height}px`,
|
|
170
189
|
});
|
|
171
190
|
}
|
|
172
191
|
|