@hypen-space/web 0.4.980 → 0.5.3
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/README.md +20 -13
- package/dist/canvas/accessibility.d.ts +118 -11
- package/dist/canvas/accessibility.js +470 -108
- package/dist/canvas/accessibility.js.map +1 -1
- package/dist/canvas/dispatch.d.ts +24 -0
- package/dist/canvas/dispatch.js +62 -0
- package/dist/canvas/dispatch.js.map +1 -0
- package/dist/canvas/editing.d.ts +178 -0
- package/dist/canvas/editing.js +590 -0
- package/dist/canvas/editing.js.map +1 -0
- package/dist/canvas/events.d.ts +28 -23
- package/dist/canvas/events.js +90 -103
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/focus.d.ts +78 -0
- package/dist/canvas/focus.js +182 -0
- package/dist/canvas/focus.js.map +1 -0
- package/dist/canvas/index.d.ts +3 -2
- package/dist/canvas/index.js +3 -2
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.js +68 -98
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +449 -110
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +28 -4
- package/dist/canvas/renderer.js +201 -81
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/selection.js +31 -177
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/text-geometry.d.ts +72 -0
- package/dist/canvas/text-geometry.js +244 -0
- package/dist/canvas/text-geometry.js.map +1 -0
- package/dist/canvas/text.js +44 -18
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +10 -1
- package/dist/canvas/utils.d.ts +0 -23
- package/dist/canvas/utils.js +3 -67
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/variants.d.ts +52 -0
- package/dist/canvas/variants.js +156 -0
- package/dist/canvas/variants.js.map +1 -0
- package/dist/dom/a11y-styles.d.ts +20 -0
- package/dist/dom/a11y-styles.js +61 -0
- package/dist/dom/a11y-styles.js.map +1 -0
- package/dist/dom/applicators/aria.d.ts +19 -0
- package/dist/dom/applicators/aria.js +36 -0
- package/dist/dom/applicators/aria.js.map +1 -0
- package/dist/dom/applicators/events.d.ts +7 -0
- package/dist/dom/applicators/events.js +36 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/font.d.ts +11 -6
- package/dist/dom/applicators/font.js +16 -27
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/index.js +51 -45
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/canvas/index.js +2 -2
- package/dist/dom/canvas/index.js.map +1 -1
- package/dist/dom/components/app.js +2 -2
- package/dist/dom/components/app.js.map +1 -1
- package/dist/dom/components/audio.js +2 -2
- package/dist/dom/components/audio.js.map +1 -1
- package/dist/dom/components/avatar.js +3 -4
- package/dist/dom/components/avatar.js.map +1 -1
- package/dist/dom/components/badge.js +3 -4
- package/dist/dom/components/badge.js.map +1 -1
- package/dist/dom/components/button.js +2 -2
- package/dist/dom/components/button.js.map +1 -1
- package/dist/dom/components/card.js +2 -2
- package/dist/dom/components/card.js.map +1 -1
- package/dist/dom/components/center.js +2 -2
- package/dist/dom/components/center.js.map +1 -1
- package/dist/dom/components/checkbox.js +4 -5
- package/dist/dom/components/checkbox.js.map +1 -1
- package/dist/dom/components/column.js +2 -2
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/container.js +2 -2
- package/dist/dom/components/container.js.map +1 -1
- package/dist/dom/components/divider.js +2 -2
- package/dist/dom/components/divider.js.map +1 -1
- package/dist/dom/components/grid.js +10 -10
- package/dist/dom/components/grid.js.map +1 -1
- package/dist/dom/components/heading.js +4 -6
- package/dist/dom/components/heading.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +215 -247
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/icon.js +4 -5
- package/dist/dom/components/icon.js.map +1 -1
- package/dist/dom/components/image.js +2 -2
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/index.d.ts +2 -2
- package/dist/dom/components/index.js +9 -2
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/input.js +2 -2
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/link.js +2 -2
- package/dist/dom/components/link.js.map +1 -1
- package/dist/dom/components/list.js +2 -2
- package/dist/dom/components/list.js.map +1 -1
- package/dist/dom/components/paragraph.js +3 -4
- package/dist/dom/components/paragraph.js.map +1 -1
- package/dist/dom/components/progressbar.js +3 -3
- package/dist/dom/components/progressbar.js.map +1 -1
- package/dist/dom/components/route.js +2 -2
- package/dist/dom/components/route.js.map +1 -1
- package/dist/dom/components/router.js +2 -2
- package/dist/dom/components/router.js.map +1 -1
- package/dist/dom/components/row.js +10 -10
- package/dist/dom/components/row.js.map +1 -1
- package/dist/dom/components/select.js +3 -4
- package/dist/dom/components/select.js.map +1 -1
- package/dist/dom/components/slider.js +2 -2
- package/dist/dom/components/slider.js.map +1 -1
- package/dist/dom/components/spacer.js +2 -2
- package/dist/dom/components/spacer.js.map +1 -1
- package/dist/dom/components/spinner.js +27 -13
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/stack.js +10 -10
- package/dist/dom/components/stack.js.map +1 -1
- package/dist/dom/components/switch.js +5 -6
- package/dist/dom/components/switch.js.map +1 -1
- package/dist/dom/components/tabs.d.ts +27 -0
- package/dist/dom/components/tabs.js +69 -0
- package/dist/dom/components/tabs.js.map +1 -0
- package/dist/dom/components/text.js +4 -7
- package/dist/dom/components/text.js.map +1 -1
- package/dist/dom/components/textarea.js +2 -2
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/components/video.js +2 -2
- package/dist/dom/components/video.js.map +1 -1
- package/dist/dom/components/visuallyhidden.d.ts +9 -0
- package/dist/dom/components/visuallyhidden.js +26 -0
- package/dist/dom/components/visuallyhidden.js.map +1 -0
- package/dist/dom/debug.js +6 -8
- package/dist/dom/debug.js.map +1 -1
- package/dist/dom/index.d.ts +0 -1
- package/dist/dom/index.js +0 -1
- package/dist/dom/index.js.map +1 -1
- package/dist/dom/operability.d.ts +100 -0
- package/dist/dom/operability.js +298 -0
- package/dist/dom/operability.js.map +1 -0
- package/dist/dom/renderer.d.ts +98 -5
- package/dist/dom/renderer.js +400 -66
- package/dist/dom/renderer.js.map +1 -1
- package/dist/dom/route-focus.d.ts +42 -0
- package/dist/dom/route-focus.js +88 -0
- package/dist/dom/route-focus.js.map +1 -0
- package/dist/dom/semantics.d.ts +35 -0
- package/dist/dom/semantics.js +184 -0
- package/dist/dom/semantics.js.map +1 -0
- package/dist/variants.d.ts +90 -0
- package/dist/variants.js +206 -0
- package/dist/variants.js.map +1 -0
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +16 -11
- package/src/canvas/README.md +63 -34
- package/src/canvas/accessibility.ts +507 -115
- package/src/canvas/dispatch.ts +78 -0
- package/src/canvas/editing.ts +697 -0
- package/src/canvas/events.ts +104 -115
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -9
- package/src/canvas/layout.ts +68 -103
- package/src/canvas/paint.ts +509 -109
- package/src/canvas/renderer.ts +239 -99
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +45 -18
- package/src/canvas/types.ts +28 -1
- package/src/canvas/utils.ts +3 -69
- package/src/canvas/variants.ts +172 -0
- package/src/dom/README.md +8 -7
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +39 -7
- package/src/dom/applicators/font.ts +16 -29
- package/src/dom/applicators/index.ts +61 -46
- package/src/dom/canvas/index.ts +2 -2
- package/src/dom/components/app.ts +2 -2
- package/src/dom/components/audio.ts +2 -2
- package/src/dom/components/avatar.ts +3 -4
- package/src/dom/components/badge.ts +3 -4
- package/src/dom/components/button.ts +2 -2
- package/src/dom/components/card.ts +2 -2
- package/src/dom/components/center.ts +2 -2
- package/src/dom/components/checkbox.ts +4 -5
- package/src/dom/components/column.ts +2 -2
- package/src/dom/components/container.ts +2 -2
- package/src/dom/components/divider.ts +2 -2
- package/src/dom/components/grid.ts +10 -10
- package/src/dom/components/heading.ts +4 -6
- package/src/dom/components/hypenapp.ts +246 -274
- package/src/dom/components/icon.ts +4 -5
- package/src/dom/components/image.ts +2 -2
- package/src/dom/components/index.ts +10 -3
- package/src/dom/components/input.ts +2 -2
- package/src/dom/components/link.ts +2 -2
- package/src/dom/components/list.ts +2 -2
- package/src/dom/components/paragraph.ts +3 -4
- package/src/dom/components/progressbar.ts +3 -3
- package/src/dom/components/route.ts +2 -2
- package/src/dom/components/router.ts +2 -2
- package/src/dom/components/row.ts +10 -10
- package/src/dom/components/select.ts +3 -4
- package/src/dom/components/slider.ts +2 -2
- package/src/dom/components/spacer.ts +2 -2
- package/src/dom/components/spinner.ts +34 -16
- package/src/dom/components/stack.ts +10 -10
- package/src/dom/components/switch.ts +5 -6
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/text.ts +4 -7
- package/src/dom/components/textarea.ts +2 -2
- package/src/dom/components/video.ts +2 -2
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/debug.ts +6 -8
- package/src/dom/index.ts +0 -8
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +457 -66
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -0
- package/src/variants.ts +249 -0
- package/dist/canvas/input.d.ts +0 -76
- package/dist/canvas/input.js +0 -207
- package/dist/canvas/input.js.map +0 -1
- package/dist/client.d.ts +0 -36
- package/dist/client.js +0 -47
- package/dist/client.js.map +0 -1
- package/src/canvas/input.ts +0 -251
- package/src/client.ts +0 -88
package/src/canvas/events.ts
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
* Hit testing and event handling for canvas nodes
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { VirtualNode, Point } from "./types.js";
|
|
7
|
+
import type { VirtualNode, Point, Rectangle } from "./types.js";
|
|
8
8
|
import { isPointInRoundedRect } from "./utils.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
9
|
+
import { dispatchNodeEvent } from "./dispatch.js";
|
|
10
|
+
import type { FocusManager } from "./focus.js";
|
|
11
11
|
|
|
12
12
|
// Interface for the engine that CanvasEventManager needs
|
|
13
13
|
interface IEngine {
|
|
@@ -16,17 +16,26 @@ interface IEngine {
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Canvas Event Manager
|
|
19
|
+
*
|
|
20
|
+
* Pointer-side interaction: hit testing, hover/pressed state, cursor, and
|
|
21
|
+
* click dispatch. Focus is NOT owned here — the pointer path funnels into
|
|
22
|
+
* the {@link FocusManager}, which treats real DOM focus on the
|
|
23
|
+
* accessibility-mirror (canvas fallback content) as the single source of
|
|
24
|
+
* truth. Keyboard events likewise arrive via the mirror (a canvas without
|
|
25
|
+
* tabindex never receives them), so this class attaches no key listeners.
|
|
19
26
|
*/
|
|
20
27
|
export class CanvasEventManager {
|
|
21
28
|
private canvas: HTMLCanvasElement;
|
|
22
29
|
private engine: IEngine;
|
|
23
30
|
private rootNode: VirtualNode | null = null;
|
|
24
31
|
private hoveredNode: VirtualNode | null = null;
|
|
25
|
-
private focusedNode: VirtualNode | null = null;
|
|
26
32
|
private mouseDownNode: VirtualNode | null = null;
|
|
27
|
-
private
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
private focusManager: FocusManager | null = null;
|
|
34
|
+
private editablePointerHandler: ((node: VirtualNode, point: Point) => void) | null = null;
|
|
35
|
+
|
|
36
|
+
// Reused for the per-node rounded-rect test so hit testing allocates
|
|
37
|
+
// nothing per visited node.
|
|
38
|
+
private scratchBounds: Rectangle = { x: 0, y: 0, width: 0, height: 0 };
|
|
30
39
|
|
|
31
40
|
// Bound handler references for cleanup
|
|
32
41
|
private boundOnMouseMove!: (e: MouseEvent) => void;
|
|
@@ -35,8 +44,6 @@ export class CanvasEventManager {
|
|
|
35
44
|
private boundOnClick!: (e: MouseEvent) => void;
|
|
36
45
|
private boundOnDoubleClick!: (e: MouseEvent) => void;
|
|
37
46
|
private boundOnContextMenu!: (e: MouseEvent) => void;
|
|
38
|
-
private boundOnKeyDown!: (e: KeyboardEvent) => void;
|
|
39
|
-
private boundOnKeyUp!: (e: KeyboardEvent) => void;
|
|
40
47
|
|
|
41
48
|
constructor(canvas: HTMLCanvasElement, engine: IEngine) {
|
|
42
49
|
this.canvas = canvas;
|
|
@@ -52,16 +59,22 @@ export class CanvasEventManager {
|
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
|
|
59
|
-
|
|
62
|
+
* Wire the focus manager the pointer path reports into. Clicking a
|
|
63
|
+
* focusable node focuses its mirror element; clicking anything else
|
|
64
|
+
* clears mirror focus.
|
|
65
|
+
*/
|
|
66
|
+
setFocusManager(fm: FocusManager | null): void {
|
|
67
|
+
this.focusManager = fm;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Called with (node, point) when a mousedown lands on a focusable node —
|
|
72
|
+
* the renderer maps the point to a caret position for editable nodes.
|
|
60
73
|
*/
|
|
61
|
-
|
|
62
|
-
fn: ((
|
|
74
|
+
setEditablePointerHandler(
|
|
75
|
+
fn: ((node: VirtualNode, point: Point) => void) | null,
|
|
63
76
|
): void {
|
|
64
|
-
this.
|
|
77
|
+
this.editablePointerHandler = fn;
|
|
65
78
|
}
|
|
66
79
|
|
|
67
80
|
/**
|
|
@@ -74,8 +87,6 @@ export class CanvasEventManager {
|
|
|
74
87
|
this.boundOnClick = this.onClick.bind(this);
|
|
75
88
|
this.boundOnDoubleClick = this.onDoubleClick.bind(this);
|
|
76
89
|
this.boundOnContextMenu = this.onContextMenu.bind(this);
|
|
77
|
-
this.boundOnKeyDown = this.onKeyDown.bind(this);
|
|
78
|
-
this.boundOnKeyUp = this.onKeyUp.bind(this);
|
|
79
90
|
|
|
80
91
|
this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
|
|
81
92
|
this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
|
|
@@ -83,8 +94,6 @@ export class CanvasEventManager {
|
|
|
83
94
|
this.canvas.addEventListener("click", this.boundOnClick);
|
|
84
95
|
this.canvas.addEventListener("dblclick", this.boundOnDoubleClick);
|
|
85
96
|
this.canvas.addEventListener("contextmenu", this.boundOnContextMenu);
|
|
86
|
-
this.canvas.addEventListener("keydown", this.boundOnKeyDown);
|
|
87
|
-
this.canvas.addEventListener("keyup", this.boundOnKeyUp);
|
|
88
97
|
}
|
|
89
98
|
|
|
90
99
|
/**
|
|
@@ -118,32 +127,64 @@ export class CanvasEventManager {
|
|
|
118
127
|
* checked first because they paint on top (see `paint.ts` overlay
|
|
119
128
|
* ordering) — without this, a click on the Story's close-button overlay
|
|
120
129
|
* lands on the underlying Image instead.
|
|
130
|
+
*
|
|
131
|
+
* `scrollX`/`scrollY` accumulate the ancestors' scroll offsets down the
|
|
132
|
+
* recursion (the translation paint applies) so no per-node ancestor walk
|
|
133
|
+
* is needed. Subtrees behind a clipping container are pruned when the
|
|
134
|
+
* point falls outside the container — nothing inside can be visible there.
|
|
121
135
|
*/
|
|
122
|
-
private hitTestNode(
|
|
136
|
+
private hitTestNode(
|
|
137
|
+
node: VirtualNode,
|
|
138
|
+
point: Point,
|
|
139
|
+
scrollX: number = 0,
|
|
140
|
+
scrollY: number = 0,
|
|
141
|
+
): VirtualNode | null {
|
|
123
142
|
if (!node.visible || !node.layout) return null;
|
|
124
143
|
|
|
125
|
-
const
|
|
126
|
-
|
|
144
|
+
const layout = node.layout;
|
|
145
|
+
const x = layout.x - scrollX;
|
|
146
|
+
const y = layout.y - scrollY;
|
|
147
|
+
const inBounds =
|
|
148
|
+
point.x >= x && point.x <= x + layout.width &&
|
|
149
|
+
point.y >= y && point.y <= y + layout.height;
|
|
150
|
+
|
|
151
|
+
if (!inBounds) {
|
|
152
|
+
const overflow = node.props.overflow;
|
|
153
|
+
if (overflow === "hidden" || overflow === "scroll" || overflow === "auto") {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
|
|
159
|
+
const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
|
|
127
160
|
|
|
128
161
|
// Front-to-back order: absolute overlays (newest in paint stack)
|
|
129
162
|
// first, then flow children in reverse paint order.
|
|
130
163
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
131
164
|
const child = node.children[i];
|
|
132
165
|
if (child.props.position !== "absolute") continue;
|
|
133
|
-
const hit = this.hitTestNode(child, point);
|
|
166
|
+
const hit = this.hitTestNode(child, point, childScrollX, childScrollY);
|
|
134
167
|
if (hit) return hit;
|
|
135
168
|
}
|
|
136
169
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
137
170
|
const child = node.children[i];
|
|
138
171
|
if (child.props.position === "absolute") continue;
|
|
139
|
-
const hit = this.hitTestNode(child, point);
|
|
172
|
+
const hit = this.hitTestNode(child, point, childScrollX, childScrollY);
|
|
140
173
|
if (hit) return hit;
|
|
141
174
|
}
|
|
142
175
|
|
|
143
176
|
// Test this node
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return node;
|
|
177
|
+
if (inBounds) {
|
|
178
|
+
const radius = layout.border.radius;
|
|
179
|
+
if (radius <= 0) return node;
|
|
180
|
+
const bounds = this.scratchBounds;
|
|
181
|
+
bounds.x = x;
|
|
182
|
+
bounds.y = y;
|
|
183
|
+
bounds.width = layout.width;
|
|
184
|
+
bounds.height = layout.height;
|
|
185
|
+
if (isPointInRoundedRect(point, bounds, radius)) {
|
|
186
|
+
return node;
|
|
187
|
+
}
|
|
147
188
|
}
|
|
148
189
|
|
|
149
190
|
return null;
|
|
@@ -202,6 +243,13 @@ export class CanvasEventManager {
|
|
|
202
243
|
* Handle mouse down
|
|
203
244
|
*/
|
|
204
245
|
private onMouseDown(e: MouseEvent): void {
|
|
246
|
+
// Suppress the browser's default mousedown focus action. The canvas
|
|
247
|
+
// itself isn't focusable, so the default would move focus to <body>
|
|
248
|
+
// AFTER this handler — undoing the mirror/proxy focus we set below and
|
|
249
|
+
// instantly ending any edit session. (Synthetic events have no default
|
|
250
|
+
// action, so this only bites with real pointers.)
|
|
251
|
+
e.preventDefault?.();
|
|
252
|
+
|
|
205
253
|
const point = this.getCanvasCoordinates(e);
|
|
206
254
|
// Same lift-to-clickable as hover so a click on a Button's Icon child
|
|
207
255
|
// dispatches against the Button (where `onClick` actually lives).
|
|
@@ -210,6 +258,13 @@ export class CanvasEventManager {
|
|
|
210
258
|
|
|
211
259
|
this.mouseDownNode = node;
|
|
212
260
|
|
|
261
|
+
// Track pressed (`:active`) state so paint-time `:active` variants resolve.
|
|
262
|
+
// Repaint so the active style appears immediately on press.
|
|
263
|
+
if (node) {
|
|
264
|
+
node.pressed = true;
|
|
265
|
+
this.requestRedraw();
|
|
266
|
+
}
|
|
267
|
+
|
|
213
268
|
if (node && node.clickable) {
|
|
214
269
|
this.dispatchNodeEvent(node, "mousedown", {
|
|
215
270
|
button: e.button,
|
|
@@ -218,11 +273,16 @@ export class CanvasEventManager {
|
|
|
218
273
|
});
|
|
219
274
|
}
|
|
220
275
|
|
|
221
|
-
//
|
|
276
|
+
// Focus routes through the mirror: focusing the node's fallback-content
|
|
277
|
+
// element makes document.activeElement the truth, and the FocusManager's
|
|
278
|
+
// focusin/focusout handlers update node state + repaint.
|
|
222
279
|
if (node && node.focusable) {
|
|
223
|
-
this.
|
|
280
|
+
this.focusManager?.requestFocus(node);
|
|
281
|
+
// After focus (which starts an edit session for Input/Textarea), let
|
|
282
|
+
// the renderer place the caret at the clicked character.
|
|
283
|
+
this.editablePointerHandler?.(node, point);
|
|
224
284
|
} else {
|
|
225
|
-
this.
|
|
285
|
+
this.focusManager?.requestFocus(null);
|
|
226
286
|
}
|
|
227
287
|
}
|
|
228
288
|
|
|
@@ -234,6 +294,13 @@ export class CanvasEventManager {
|
|
|
234
294
|
const hit = this.hitTest(point);
|
|
235
295
|
const node = this.findClickableAncestor(hit) ?? hit;
|
|
236
296
|
|
|
297
|
+
// Clear the pressed (`:active`) flag from the node that was pressed —
|
|
298
|
+
// release ends `:active` even if the pointer drifted off the node first.
|
|
299
|
+
if (this.mouseDownNode && this.mouseDownNode.pressed) {
|
|
300
|
+
this.mouseDownNode.pressed = false;
|
|
301
|
+
this.requestRedraw();
|
|
302
|
+
}
|
|
303
|
+
|
|
237
304
|
if (node && node.clickable) {
|
|
238
305
|
this.dispatchNodeEvent(node, "mouseup", {
|
|
239
306
|
button: e.button,
|
|
@@ -304,62 +371,6 @@ export class CanvasEventManager {
|
|
|
304
371
|
}
|
|
305
372
|
}
|
|
306
373
|
|
|
307
|
-
/**
|
|
308
|
-
* Handle keyboard events
|
|
309
|
-
*/
|
|
310
|
-
private onKeyDown(e: KeyboardEvent): void {
|
|
311
|
-
if (this.focusedNode) {
|
|
312
|
-
this.dispatchNodeEvent(this.focusedNode, "keydown", {
|
|
313
|
-
key: e.key,
|
|
314
|
-
code: e.code,
|
|
315
|
-
ctrlKey: e.ctrlKey,
|
|
316
|
-
shiftKey: e.shiftKey,
|
|
317
|
-
altKey: e.altKey,
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
private onKeyUp(e: KeyboardEvent): void {
|
|
323
|
-
if (this.focusedNode) {
|
|
324
|
-
this.dispatchNodeEvent(this.focusedNode, "keyup", {
|
|
325
|
-
key: e.key,
|
|
326
|
-
code: e.code,
|
|
327
|
-
ctrlKey: e.ctrlKey,
|
|
328
|
-
shiftKey: e.shiftKey,
|
|
329
|
-
altKey: e.altKey,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Set focused node
|
|
336
|
-
*/
|
|
337
|
-
private setFocus(node: VirtualNode | null): void {
|
|
338
|
-
if (node === this.focusedNode) return;
|
|
339
|
-
|
|
340
|
-
const prev = this.focusedNode;
|
|
341
|
-
if (prev) {
|
|
342
|
-
prev.focused = false;
|
|
343
|
-
this.dispatchNodeEvent(prev, "blur", {});
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
this.focusedNode = node;
|
|
347
|
-
|
|
348
|
-
if (node) {
|
|
349
|
-
node.focused = true;
|
|
350
|
-
this.dispatchNodeEvent(node, "focus", {});
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
if (this.focusChangeHandler) this.focusChangeHandler(node, prev);
|
|
354
|
-
|
|
355
|
-
this.requestRedraw();
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/** Public for the renderer's overlay-blur path. */
|
|
359
|
-
clearFocus(): void {
|
|
360
|
-
this.setFocus(null);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
374
|
/**
|
|
364
375
|
* Update cursor based on node
|
|
365
376
|
*/
|
|
@@ -380,32 +391,11 @@ export class CanvasEventManager {
|
|
|
380
391
|
}
|
|
381
392
|
|
|
382
393
|
/**
|
|
383
|
-
* Dispatch event to engine
|
|
394
|
+
* Dispatch event to engine (shared resolver — same payload shape as the
|
|
395
|
+
* mirror's keyboard/AT path).
|
|
384
396
|
*/
|
|
385
397
|
private dispatchNodeEvent(node: VirtualNode, eventType: string, data: any): void {
|
|
386
|
-
|
|
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;
|
|
397
|
-
}
|
|
398
|
-
|
|
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
|
-
});
|
|
398
|
+
dispatchNodeEvent(this.engine, node, eventType, data);
|
|
409
399
|
}
|
|
410
400
|
|
|
411
401
|
/**
|
|
@@ -427,12 +417,11 @@ export class CanvasEventManager {
|
|
|
427
417
|
this.canvas.removeEventListener("click", this.boundOnClick);
|
|
428
418
|
this.canvas.removeEventListener("dblclick", this.boundOnDoubleClick);
|
|
429
419
|
this.canvas.removeEventListener("contextmenu", this.boundOnContextMenu);
|
|
430
|
-
this.
|
|
431
|
-
this.canvas.removeEventListener("keyup", this.boundOnKeyUp);
|
|
420
|
+
if (this.mouseDownNode) this.mouseDownNode.pressed = false;
|
|
432
421
|
this.rootNode = null;
|
|
433
422
|
this.hoveredNode = null;
|
|
434
|
-
this.focusedNode = null;
|
|
435
423
|
this.mouseDownNode = null;
|
|
424
|
+
this.focusManager = null;
|
|
436
425
|
}
|
|
437
426
|
}
|
|
438
427
|
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus Manager
|
|
3
|
+
*
|
|
4
|
+
* Real DOM focus on accessibility-mirror elements (canvas fallback content)
|
|
5
|
+
* is the single source of truth for canvas focus. Tab/Shift+Tab traverse
|
|
6
|
+
* the mirror natively; `focusin`/`focusout` translate DOM focus into
|
|
7
|
+
* `node.focused` + repaint; the pointer path (canvas hit-test) funnels into
|
|
8
|
+
* the same place by focusing the node's mirror element.
|
|
9
|
+
*
|
|
10
|
+
* Loop-free by construction: DOM focus events only ever *read* into node
|
|
11
|
+
* state — nothing in the sync path calls `.focus()` back.
|
|
12
|
+
*
|
|
13
|
+
* The mirror also carries keyboard/AT interaction back to the engine:
|
|
14
|
+
* Enter/Space on a mirror `<button>` produces a native `click` (dispatched
|
|
15
|
+
* to the node's action), and keydown/keyup on the focused element dispatch
|
|
16
|
+
* the node's `onKeyDown`/`onKeyUp` applicators — which the old
|
|
17
|
+
* canvas-attached listeners never could (a canvas without tabindex never
|
|
18
|
+
* receives key events).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { VirtualNode } from "./types.js";
|
|
22
|
+
import type { AccessibilityLayer } from "./accessibility.js";
|
|
23
|
+
import { dispatchNodeEvent, type DispatchEngine } from "./dispatch.js";
|
|
24
|
+
|
|
25
|
+
export interface FocusManagerHooks {
|
|
26
|
+
getNode(id: string): VirtualNode | undefined;
|
|
27
|
+
/** Fired after node.focused flags are updated. Renderer repaints and
|
|
28
|
+
* (for editable nodes) starts/stops the text-edit controller. */
|
|
29
|
+
onFocusChange(next: VirtualNode | null, prev: VirtualNode | null): void;
|
|
30
|
+
/**
|
|
31
|
+
* Elements outside the mirror that still count as "the node is focused" —
|
|
32
|
+
* the text editor's hidden IME proxy. Focus moving there must not clear
|
|
33
|
+
* canvas focus.
|
|
34
|
+
*/
|
|
35
|
+
isAuxFocusTarget?(element: unknown): boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class FocusManager {
|
|
39
|
+
private mirror: AccessibilityLayer;
|
|
40
|
+
private engine: DispatchEngine;
|
|
41
|
+
private hooks: FocusManagerHooks;
|
|
42
|
+
private focusedNode: VirtualNode | null = null;
|
|
43
|
+
|
|
44
|
+
private boundFocusIn = (e: Event) => this.onFocusIn(e as FocusEvent);
|
|
45
|
+
private boundFocusOut = (e: Event) => this.onFocusOut(e as FocusEvent);
|
|
46
|
+
private boundClick = (e: Event) => this.onMirrorClick(e);
|
|
47
|
+
private boundKeyDown = (e: Event) => this.onMirrorKey("keydown", e as KeyboardEvent);
|
|
48
|
+
private boundKeyUp = (e: Event) => this.onMirrorKey("keyup", e as KeyboardEvent);
|
|
49
|
+
|
|
50
|
+
constructor(mirror: AccessibilityLayer, engine: DispatchEngine, hooks: FocusManagerHooks) {
|
|
51
|
+
this.mirror = mirror;
|
|
52
|
+
this.engine = engine;
|
|
53
|
+
this.hooks = hooks;
|
|
54
|
+
|
|
55
|
+
const root = mirror.getRoot();
|
|
56
|
+
if (root && typeof root.addEventListener === "function") {
|
|
57
|
+
root.addEventListener("focusin", this.boundFocusIn);
|
|
58
|
+
root.addEventListener("focusout", this.boundFocusOut);
|
|
59
|
+
root.addEventListener("click", this.boundClick);
|
|
60
|
+
root.addEventListener("keydown", this.boundKeyDown);
|
|
61
|
+
root.addEventListener("keyup", this.boundKeyUp);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// -------------------------------------------------------------------------
|
|
66
|
+
// DOM → node state (the only direction focus state flows)
|
|
67
|
+
// -------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/** Climb from an event target to the mirror element's virtual node. */
|
|
70
|
+
private resolveNode(target: EventTarget | null): VirtualNode | null {
|
|
71
|
+
let el = target as (HTMLElement & { parentNode?: any }) | null;
|
|
72
|
+
const root = this.mirror.getRoot();
|
|
73
|
+
while (el && el !== root) {
|
|
74
|
+
const id = el.getAttribute?.("data-hypen-id");
|
|
75
|
+
if (id) {
|
|
76
|
+
return this.hooks.getNode(id) ?? null;
|
|
77
|
+
}
|
|
78
|
+
el = el.parentNode ?? null;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private onFocusIn(e: FocusEvent): void {
|
|
84
|
+
this.syncFocus(this.resolveNode(e.target));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private onFocusOut(e: FocusEvent): void {
|
|
88
|
+
// If focus moves to another mirror element, its focusin handles the
|
|
89
|
+
// transition; a handoff to the editor's IME proxy keeps the node
|
|
90
|
+
// focused; only a departure from both clears focus.
|
|
91
|
+
const next = e.relatedTarget as HTMLElement | null;
|
|
92
|
+
if (next && this.hooks.isAuxFocusTarget?.(next)) return;
|
|
93
|
+
const root = this.mirror.getRoot();
|
|
94
|
+
if (!next || !root || !(root.contains?.(next) ?? false)) {
|
|
95
|
+
this.syncFocus(null);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Update node.focused flags from the new DOM focus target. Idempotent. */
|
|
100
|
+
private syncFocus(node: VirtualNode | null): void {
|
|
101
|
+
if (node === this.focusedNode) return;
|
|
102
|
+
|
|
103
|
+
const prev = this.focusedNode;
|
|
104
|
+
if (prev) {
|
|
105
|
+
prev.focused = false;
|
|
106
|
+
dispatchNodeEvent(this.engine, prev, "blur", {});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.focusedNode = node;
|
|
110
|
+
if (node) {
|
|
111
|
+
node.focused = true;
|
|
112
|
+
dispatchNodeEvent(this.engine, node, "focus", {});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.hooks.onFocusChange(node, prev);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// -------------------------------------------------------------------------
|
|
119
|
+
// Pointer path — canvas hit-test funnels into DOM focus
|
|
120
|
+
// -------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Focus a node from the canvas pointer path. Focuses the mirror element
|
|
124
|
+
* (so `document.activeElement` agrees) and syncs node state directly —
|
|
125
|
+
* in real browsers the element's focusin already did that synchronously
|
|
126
|
+
* and the second sync is a no-op; in environments without focus events
|
|
127
|
+
* the direct sync keeps behavior identical.
|
|
128
|
+
*/
|
|
129
|
+
requestFocus(node: VirtualNode | null): void {
|
|
130
|
+
if (!node) {
|
|
131
|
+
this.clearFocus();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const el = this.mirror.getElement(node.id);
|
|
136
|
+
(el as any)?.focus?.({ preventScroll: true });
|
|
137
|
+
this.syncFocus(node);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Pointer landed on nothing focusable: blur any mirror focus and clear. */
|
|
141
|
+
clearFocus(): void {
|
|
142
|
+
if (typeof document !== "undefined") {
|
|
143
|
+
const active = document.activeElement as HTMLElement | null;
|
|
144
|
+
const root = this.mirror.getRoot();
|
|
145
|
+
if (
|
|
146
|
+
active &&
|
|
147
|
+
((root && (root.contains?.(active) ?? false)) ||
|
|
148
|
+
this.hooks.isAuxFocusTarget?.(active))
|
|
149
|
+
) {
|
|
150
|
+
active.blur?.();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
this.syncFocus(null);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Clear focus if the focused node is `node` or one of its descendants —
|
|
158
|
+
* called before a subtree is removed/detached (the mirror element may
|
|
159
|
+
* leave the DOM without a browser blur when focus sits on the IME proxy).
|
|
160
|
+
*/
|
|
161
|
+
clearIfWithin(node: VirtualNode): void {
|
|
162
|
+
let current: VirtualNode | null = this.focusedNode;
|
|
163
|
+
while (current) {
|
|
164
|
+
if (current === node) {
|
|
165
|
+
this.syncFocus(null);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
current = current.parent;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
getFocusedNode(): VirtualNode | null {
|
|
173
|
+
return this.focusedNode;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// -------------------------------------------------------------------------
|
|
177
|
+
// Mirror interaction → engine actions
|
|
178
|
+
// -------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Native click on a mirror element — keyboard activation (Enter/Space on
|
|
182
|
+
* a real `<button>`) or an AT-initiated action. Pointer clicks never land
|
|
183
|
+
* here (fallback content has no rendered boxes), so this can't double-fire
|
|
184
|
+
* with the canvas hit-test click path.
|
|
185
|
+
*/
|
|
186
|
+
private onMirrorClick(e: Event): void {
|
|
187
|
+
const node = this.resolveNode(e.target);
|
|
188
|
+
if (!node) return;
|
|
189
|
+
if (!node.clickable) return;
|
|
190
|
+
dispatchNodeEvent(this.engine, node, "click", {});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private onMirrorKey(type: "keydown" | "keyup", e: KeyboardEvent): void {
|
|
194
|
+
const node = this.resolveNode(e.target);
|
|
195
|
+
if (!node) return;
|
|
196
|
+
dispatchNodeEvent(this.engine, node, type, {
|
|
197
|
+
key: e.key,
|
|
198
|
+
code: e.code,
|
|
199
|
+
ctrlKey: e.ctrlKey,
|
|
200
|
+
shiftKey: e.shiftKey,
|
|
201
|
+
altKey: e.altKey,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
destroy(): void {
|
|
206
|
+
const root = this.mirror.getRoot();
|
|
207
|
+
if (root && typeof root.removeEventListener === "function") {
|
|
208
|
+
root.removeEventListener("focusin", this.boundFocusIn);
|
|
209
|
+
root.removeEventListener("focusout", this.boundFocusOut);
|
|
210
|
+
root.removeEventListener("click", this.boundClick);
|
|
211
|
+
root.removeEventListener("keydown", this.boundKeyDown);
|
|
212
|
+
root.removeEventListener("keyup", this.boundKeyUp);
|
|
213
|
+
}
|
|
214
|
+
this.focusedNode = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
package/src/canvas/index.ts
CHANGED
|
@@ -5,22 +5,21 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export { CanvasRenderer } from "./renderer.js";
|
|
8
|
-
export {
|
|
9
|
-
createCanvasClient as createHypenClient,
|
|
10
|
-
createCanvasClient,
|
|
11
|
-
type CanvasClient as HypenClient,
|
|
12
|
-
type CanvasClient,
|
|
13
|
-
type CanvasClientOptions as HypenClientOptions,
|
|
14
|
-
type CanvasClientOptions,
|
|
15
|
-
} from "../client.js";
|
|
16
8
|
export { registerPainter } from "./paint.js";
|
|
17
9
|
export { CanvasEventManager } from "./events.js";
|
|
18
|
-
export { InputOverlay } from "./input.js";
|
|
19
10
|
export { AccessibilityLayer } from "./accessibility.js";
|
|
11
|
+
export { FocusManager } from "./focus.js";
|
|
12
|
+
export { TextEditController } from "./editing.js";
|
|
20
13
|
export { initTaffyLayout } from "./layout.js";
|
|
21
14
|
export { ScrollManager } from "./scroll.js";
|
|
22
15
|
export { DirtyRectTracker } from "./dirty.js";
|
|
23
16
|
export { SelectionManager } from "./selection.js";
|
|
17
|
+
export {
|
|
18
|
+
pointToOffset,
|
|
19
|
+
offsetToCaretRect,
|
|
20
|
+
rangeToRects,
|
|
21
|
+
type TextGeometry,
|
|
22
|
+
} from "./text-geometry.js";
|
|
24
23
|
|
|
25
24
|
export type {
|
|
26
25
|
VirtualNode,
|