@hypen-space/web 0.5.2 → 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/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 +76 -128
- 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 -1
- package/dist/canvas/index.js +3 -1
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +283 -41
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +20 -1
- package/dist/canvas/renderer.js +181 -50
- 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 +42 -9
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +7 -1
- package/dist/canvas/variants.d.ts +4 -1
- package/dist/canvas/variants.js +22 -3
- package/dist/canvas/variants.js.map +1 -1
- 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 +35 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/index.js +4 -0
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +213 -245
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/index.js +7 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/spinner.js +24 -10
- package/dist/dom/components/spinner.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/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/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 +398 -63
- 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/package.json +2 -2
- package/src/canvas/QUICKSTART.md +4 -4
- package/src/canvas/README.md +55 -29
- 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 +89 -142
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -1
- package/src/canvas/paint.ts +339 -42
- package/src/canvas/renderer.ts +218 -70
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +43 -9
- package/src/canvas/types.ts +14 -1
- package/src/canvas/variants.ts +24 -5
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +38 -7
- package/src/dom/applicators/index.ts +5 -0
- package/src/dom/components/hypenapp.ts +244 -272
- package/src/dom/components/index.ts +7 -0
- package/src/dom/components/spinner.ts +31 -13
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +455 -63
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -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/src/canvas/input.ts +0 -251
package/src/canvas/events.ts
CHANGED
|
@@ -4,44 +4,38 @@
|
|
|
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 {
|
|
14
14
|
dispatchAction(name: string, payload?: any): void;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
// Maps a DOM event type to the applicator prop names the engine may have set
|
|
18
|
-
// on a node, in priority order. Multi-word events (`mouseenter`) need their
|
|
19
|
-
// proper camelCase form (`onMouseEnter`) since the engine emits applicator
|
|
20
|
-
// names verbatim. `mouseenter` also accepts `onHover` as an alias.
|
|
21
|
-
const CANVAS_EVENT_PROP_NAMES: Record<string, string[]> = {
|
|
22
|
-
mouseenter: ["onMouseEnter", "onHover", "onmouseenter", "mouseenter"],
|
|
23
|
-
mouseleave: ["onMouseLeave", "onmouseleave", "mouseleave"],
|
|
24
|
-
mousedown: ["onMouseDown", "onmousedown", "mousedown"],
|
|
25
|
-
mouseup: ["onMouseUp", "onmouseup", "mouseup"],
|
|
26
|
-
dblclick: ["onDblClick", "onDoubleClick", "ondblclick", "dblclick"],
|
|
27
|
-
contextmenu: ["onContextMenu", "oncontextmenu", "contextmenu"],
|
|
28
|
-
keydown: ["onKeyDown", "onkeydown", "keydown"],
|
|
29
|
-
keyup: ["onKeyUp", "onkeyup", "keyup"],
|
|
30
|
-
};
|
|
31
|
-
|
|
32
17
|
/**
|
|
33
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.
|
|
34
26
|
*/
|
|
35
27
|
export class CanvasEventManager {
|
|
36
28
|
private canvas: HTMLCanvasElement;
|
|
37
29
|
private engine: IEngine;
|
|
38
30
|
private rootNode: VirtualNode | null = null;
|
|
39
31
|
private hoveredNode: VirtualNode | null = null;
|
|
40
|
-
private focusedNode: VirtualNode | null = null;
|
|
41
32
|
private mouseDownNode: VirtualNode | null = null;
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
|
|
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 };
|
|
45
39
|
|
|
46
40
|
// Bound handler references for cleanup
|
|
47
41
|
private boundOnMouseMove!: (e: MouseEvent) => void;
|
|
@@ -50,8 +44,6 @@ export class CanvasEventManager {
|
|
|
50
44
|
private boundOnClick!: (e: MouseEvent) => void;
|
|
51
45
|
private boundOnDoubleClick!: (e: MouseEvent) => void;
|
|
52
46
|
private boundOnContextMenu!: (e: MouseEvent) => void;
|
|
53
|
-
private boundOnKeyDown!: (e: KeyboardEvent) => void;
|
|
54
|
-
private boundOnKeyUp!: (e: KeyboardEvent) => void;
|
|
55
47
|
|
|
56
48
|
constructor(canvas: HTMLCanvasElement, engine: IEngine) {
|
|
57
49
|
this.canvas = canvas;
|
|
@@ -67,16 +59,22 @@ export class CanvasEventManager {
|
|
|
67
59
|
}
|
|
68
60
|
|
|
69
61
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
* Input painted as a pretty pill but a click on it did nothing — the
|
|
74
|
-
* overlay was constructed but never invoked.
|
|
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.
|
|
75
65
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
setEditablePointerHandler(
|
|
75
|
+
fn: ((node: VirtualNode, point: Point) => void) | null,
|
|
78
76
|
): void {
|
|
79
|
-
this.
|
|
77
|
+
this.editablePointerHandler = fn;
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
/**
|
|
@@ -89,8 +87,6 @@ export class CanvasEventManager {
|
|
|
89
87
|
this.boundOnClick = this.onClick.bind(this);
|
|
90
88
|
this.boundOnDoubleClick = this.onDoubleClick.bind(this);
|
|
91
89
|
this.boundOnContextMenu = this.onContextMenu.bind(this);
|
|
92
|
-
this.boundOnKeyDown = this.onKeyDown.bind(this);
|
|
93
|
-
this.boundOnKeyUp = this.onKeyUp.bind(this);
|
|
94
90
|
|
|
95
91
|
this.canvas.addEventListener("mousemove", this.boundOnMouseMove);
|
|
96
92
|
this.canvas.addEventListener("mousedown", this.boundOnMouseDown);
|
|
@@ -98,8 +94,6 @@ export class CanvasEventManager {
|
|
|
98
94
|
this.canvas.addEventListener("click", this.boundOnClick);
|
|
99
95
|
this.canvas.addEventListener("dblclick", this.boundOnDoubleClick);
|
|
100
96
|
this.canvas.addEventListener("contextmenu", this.boundOnContextMenu);
|
|
101
|
-
this.canvas.addEventListener("keydown", this.boundOnKeyDown);
|
|
102
|
-
this.canvas.addEventListener("keyup", this.boundOnKeyUp);
|
|
103
97
|
}
|
|
104
98
|
|
|
105
99
|
/**
|
|
@@ -133,32 +127,64 @@ export class CanvasEventManager {
|
|
|
133
127
|
* checked first because they paint on top (see `paint.ts` overlay
|
|
134
128
|
* ordering) — without this, a click on the Story's close-button overlay
|
|
135
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.
|
|
136
135
|
*/
|
|
137
|
-
private hitTestNode(
|
|
136
|
+
private hitTestNode(
|
|
137
|
+
node: VirtualNode,
|
|
138
|
+
point: Point,
|
|
139
|
+
scrollX: number = 0,
|
|
140
|
+
scrollY: number = 0,
|
|
141
|
+
): VirtualNode | null {
|
|
138
142
|
if (!node.visible || !node.layout) return null;
|
|
139
143
|
|
|
140
|
-
const
|
|
141
|
-
|
|
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);
|
|
142
160
|
|
|
143
161
|
// Front-to-back order: absolute overlays (newest in paint stack)
|
|
144
162
|
// first, then flow children in reverse paint order.
|
|
145
163
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
146
164
|
const child = node.children[i];
|
|
147
165
|
if (child.props.position !== "absolute") continue;
|
|
148
|
-
const hit = this.hitTestNode(child, point);
|
|
166
|
+
const hit = this.hitTestNode(child, point, childScrollX, childScrollY);
|
|
149
167
|
if (hit) return hit;
|
|
150
168
|
}
|
|
151
169
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
152
170
|
const child = node.children[i];
|
|
153
171
|
if (child.props.position === "absolute") continue;
|
|
154
|
-
const hit = this.hitTestNode(child, point);
|
|
172
|
+
const hit = this.hitTestNode(child, point, childScrollX, childScrollY);
|
|
155
173
|
if (hit) return hit;
|
|
156
174
|
}
|
|
157
175
|
|
|
158
176
|
// Test this node
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
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
|
+
}
|
|
162
188
|
}
|
|
163
189
|
|
|
164
190
|
return null;
|
|
@@ -217,6 +243,13 @@ export class CanvasEventManager {
|
|
|
217
243
|
* Handle mouse down
|
|
218
244
|
*/
|
|
219
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
|
+
|
|
220
253
|
const point = this.getCanvasCoordinates(e);
|
|
221
254
|
// Same lift-to-clickable as hover so a click on a Button's Icon child
|
|
222
255
|
// dispatches against the Button (where `onClick` actually lives).
|
|
@@ -240,11 +273,16 @@ export class CanvasEventManager {
|
|
|
240
273
|
});
|
|
241
274
|
}
|
|
242
275
|
|
|
243
|
-
//
|
|
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.
|
|
244
279
|
if (node && node.focusable) {
|
|
245
|
-
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);
|
|
246
284
|
} else {
|
|
247
|
-
this.
|
|
285
|
+
this.focusManager?.requestFocus(null);
|
|
248
286
|
}
|
|
249
287
|
}
|
|
250
288
|
|
|
@@ -333,62 +371,6 @@ export class CanvasEventManager {
|
|
|
333
371
|
}
|
|
334
372
|
}
|
|
335
373
|
|
|
336
|
-
/**
|
|
337
|
-
* Handle keyboard events
|
|
338
|
-
*/
|
|
339
|
-
private onKeyDown(e: KeyboardEvent): void {
|
|
340
|
-
if (this.focusedNode) {
|
|
341
|
-
this.dispatchNodeEvent(this.focusedNode, "keydown", {
|
|
342
|
-
key: e.key,
|
|
343
|
-
code: e.code,
|
|
344
|
-
ctrlKey: e.ctrlKey,
|
|
345
|
-
shiftKey: e.shiftKey,
|
|
346
|
-
altKey: e.altKey,
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
private onKeyUp(e: KeyboardEvent): void {
|
|
352
|
-
if (this.focusedNode) {
|
|
353
|
-
this.dispatchNodeEvent(this.focusedNode, "keyup", {
|
|
354
|
-
key: e.key,
|
|
355
|
-
code: e.code,
|
|
356
|
-
ctrlKey: e.ctrlKey,
|
|
357
|
-
shiftKey: e.shiftKey,
|
|
358
|
-
altKey: e.altKey,
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Set focused node
|
|
365
|
-
*/
|
|
366
|
-
private setFocus(node: VirtualNode | null): void {
|
|
367
|
-
if (node === this.focusedNode) return;
|
|
368
|
-
|
|
369
|
-
const prev = this.focusedNode;
|
|
370
|
-
if (prev) {
|
|
371
|
-
prev.focused = false;
|
|
372
|
-
this.dispatchNodeEvent(prev, "blur", {});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
this.focusedNode = node;
|
|
376
|
-
|
|
377
|
-
if (node) {
|
|
378
|
-
node.focused = true;
|
|
379
|
-
this.dispatchNodeEvent(node, "focus", {});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (this.focusChangeHandler) this.focusChangeHandler(node, prev);
|
|
383
|
-
|
|
384
|
-
this.requestRedraw();
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Public for the renderer's overlay-blur path. */
|
|
388
|
-
clearFocus(): void {
|
|
389
|
-
this.setFocus(null);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
374
|
/**
|
|
393
375
|
* Update cursor based on node
|
|
394
376
|
*/
|
|
@@ -409,44 +391,11 @@ export class CanvasEventManager {
|
|
|
409
391
|
}
|
|
410
392
|
|
|
411
393
|
/**
|
|
412
|
-
* Dispatch event to engine
|
|
394
|
+
* Dispatch event to engine (shared resolver — same payload shape as the
|
|
395
|
+
* mirror's keyboard/AT path).
|
|
413
396
|
*/
|
|
414
397
|
private dispatchNodeEvent(node: VirtualNode, eventType: string, data: any): void {
|
|
415
|
-
|
|
416
|
-
// Multi-word DOM events like `mouseenter` must map to `onMouseEnter`, not
|
|
417
|
-
// the naive `onMouseenter` that `on${capitalize(eventType)}` would produce.
|
|
418
|
-
// The older flat form `onclick`/`onmouseenter` is still accepted. After
|
|
419
|
-
// prop normalisation the value is either a string (action name) or an
|
|
420
|
-
// object carrying an action name at `"0"` plus an auxiliary payload.
|
|
421
|
-
const propNames = CANVAS_EVENT_PROP_NAMES[eventType] ?? [
|
|
422
|
-
`on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`,
|
|
423
|
-
`on${eventType}`,
|
|
424
|
-
eventType,
|
|
425
|
-
];
|
|
426
|
-
|
|
427
|
-
let spec: unknown;
|
|
428
|
-
for (const name of propNames) {
|
|
429
|
-
if (node.props[name] != null) {
|
|
430
|
-
spec = node.props[name];
|
|
431
|
-
break;
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
// Actionable components fall back to the bare `action` prop on click.
|
|
436
|
-
if (spec == null && eventType === "click") {
|
|
437
|
-
spec = node.props.action;
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
const resolved = resolveEventAction(spec);
|
|
441
|
-
if (!resolved) return;
|
|
442
|
-
|
|
443
|
-
this.engine.dispatchAction(resolved.actionName, {
|
|
444
|
-
type: eventType,
|
|
445
|
-
nodeId: node.id,
|
|
446
|
-
timestamp: Date.now(),
|
|
447
|
-
...resolved.payload,
|
|
448
|
-
...data,
|
|
449
|
-
});
|
|
398
|
+
dispatchNodeEvent(this.engine, node, eventType, data);
|
|
450
399
|
}
|
|
451
400
|
|
|
452
401
|
/**
|
|
@@ -468,13 +417,11 @@ export class CanvasEventManager {
|
|
|
468
417
|
this.canvas.removeEventListener("click", this.boundOnClick);
|
|
469
418
|
this.canvas.removeEventListener("dblclick", this.boundOnDoubleClick);
|
|
470
419
|
this.canvas.removeEventListener("contextmenu", this.boundOnContextMenu);
|
|
471
|
-
this.canvas.removeEventListener("keydown", this.boundOnKeyDown);
|
|
472
|
-
this.canvas.removeEventListener("keyup", this.boundOnKeyUp);
|
|
473
420
|
if (this.mouseDownNode) this.mouseDownNode.pressed = false;
|
|
474
421
|
this.rootNode = null;
|
|
475
422
|
this.hoveredNode = null;
|
|
476
|
-
this.focusedNode = null;
|
|
477
423
|
this.mouseDownNode = null;
|
|
424
|
+
this.focusManager = null;
|
|
478
425
|
}
|
|
479
426
|
}
|
|
480
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
|
@@ -7,12 +7,19 @@
|
|
|
7
7
|
export { CanvasRenderer } from "./renderer.js";
|
|
8
8
|
export { registerPainter } from "./paint.js";
|
|
9
9
|
export { CanvasEventManager } from "./events.js";
|
|
10
|
-
export { InputOverlay } from "./input.js";
|
|
11
10
|
export { AccessibilityLayer } from "./accessibility.js";
|
|
11
|
+
export { FocusManager } from "./focus.js";
|
|
12
|
+
export { TextEditController } from "./editing.js";
|
|
12
13
|
export { initTaffyLayout } from "./layout.js";
|
|
13
14
|
export { ScrollManager } from "./scroll.js";
|
|
14
15
|
export { DirtyRectTracker } from "./dirty.js";
|
|
15
16
|
export { SelectionManager } from "./selection.js";
|
|
17
|
+
export {
|
|
18
|
+
pointToOffset,
|
|
19
|
+
offsetToCaretRect,
|
|
20
|
+
rangeToRects,
|
|
21
|
+
type TextGeometry,
|
|
22
|
+
} from "./text-geometry.js";
|
|
16
23
|
|
|
17
24
|
export type {
|
|
18
25
|
VirtualNode,
|