@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.
Files changed (104) hide show
  1. package/dist/canvas/accessibility.d.ts +118 -11
  2. package/dist/canvas/accessibility.js +470 -108
  3. package/dist/canvas/accessibility.js.map +1 -1
  4. package/dist/canvas/dispatch.d.ts +24 -0
  5. package/dist/canvas/dispatch.js +62 -0
  6. package/dist/canvas/dispatch.js.map +1 -0
  7. package/dist/canvas/editing.d.ts +178 -0
  8. package/dist/canvas/editing.js +590 -0
  9. package/dist/canvas/editing.js.map +1 -0
  10. package/dist/canvas/events.d.ts +28 -23
  11. package/dist/canvas/events.js +76 -128
  12. package/dist/canvas/events.js.map +1 -1
  13. package/dist/canvas/focus.d.ts +78 -0
  14. package/dist/canvas/focus.js +182 -0
  15. package/dist/canvas/focus.js.map +1 -0
  16. package/dist/canvas/index.d.ts +3 -1
  17. package/dist/canvas/index.js +3 -1
  18. package/dist/canvas/index.js.map +1 -1
  19. package/dist/canvas/paint.d.ts +16 -3
  20. package/dist/canvas/paint.js +283 -41
  21. package/dist/canvas/paint.js.map +1 -1
  22. package/dist/canvas/renderer.d.ts +20 -1
  23. package/dist/canvas/renderer.js +181 -50
  24. package/dist/canvas/renderer.js.map +1 -1
  25. package/dist/canvas/selection.js +31 -177
  26. package/dist/canvas/selection.js.map +1 -1
  27. package/dist/canvas/text-geometry.d.ts +72 -0
  28. package/dist/canvas/text-geometry.js +244 -0
  29. package/dist/canvas/text-geometry.js.map +1 -0
  30. package/dist/canvas/text.js +42 -9
  31. package/dist/canvas/text.js.map +1 -1
  32. package/dist/canvas/types.d.ts +7 -1
  33. package/dist/canvas/variants.d.ts +4 -1
  34. package/dist/canvas/variants.js +22 -3
  35. package/dist/canvas/variants.js.map +1 -1
  36. package/dist/dom/a11y-styles.d.ts +20 -0
  37. package/dist/dom/a11y-styles.js +61 -0
  38. package/dist/dom/a11y-styles.js.map +1 -0
  39. package/dist/dom/applicators/aria.d.ts +19 -0
  40. package/dist/dom/applicators/aria.js +36 -0
  41. package/dist/dom/applicators/aria.js.map +1 -0
  42. package/dist/dom/applicators/events.d.ts +7 -0
  43. package/dist/dom/applicators/events.js +35 -8
  44. package/dist/dom/applicators/events.js.map +1 -1
  45. package/dist/dom/applicators/index.js +4 -0
  46. package/dist/dom/applicators/index.js.map +1 -1
  47. package/dist/dom/components/hypenapp.d.ts +25 -1
  48. package/dist/dom/components/hypenapp.js +213 -245
  49. package/dist/dom/components/hypenapp.js.map +1 -1
  50. package/dist/dom/components/index.js +7 -0
  51. package/dist/dom/components/index.js.map +1 -1
  52. package/dist/dom/components/spinner.js +24 -10
  53. package/dist/dom/components/spinner.js.map +1 -1
  54. package/dist/dom/components/tabs.d.ts +27 -0
  55. package/dist/dom/components/tabs.js +69 -0
  56. package/dist/dom/components/tabs.js.map +1 -0
  57. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  58. package/dist/dom/components/visuallyhidden.js +26 -0
  59. package/dist/dom/components/visuallyhidden.js.map +1 -0
  60. package/dist/dom/operability.d.ts +100 -0
  61. package/dist/dom/operability.js +298 -0
  62. package/dist/dom/operability.js.map +1 -0
  63. package/dist/dom/renderer.d.ts +98 -5
  64. package/dist/dom/renderer.js +398 -63
  65. package/dist/dom/renderer.js.map +1 -1
  66. package/dist/dom/route-focus.d.ts +42 -0
  67. package/dist/dom/route-focus.js +88 -0
  68. package/dist/dom/route-focus.js.map +1 -0
  69. package/dist/dom/semantics.d.ts +35 -0
  70. package/dist/dom/semantics.js +184 -0
  71. package/dist/dom/semantics.js.map +1 -0
  72. package/package.json +2 -2
  73. package/src/canvas/QUICKSTART.md +4 -4
  74. package/src/canvas/README.md +55 -29
  75. package/src/canvas/accessibility.ts +507 -115
  76. package/src/canvas/dispatch.ts +78 -0
  77. package/src/canvas/editing.ts +697 -0
  78. package/src/canvas/events.ts +89 -142
  79. package/src/canvas/focus.ts +216 -0
  80. package/src/canvas/index.ts +8 -1
  81. package/src/canvas/paint.ts +339 -42
  82. package/src/canvas/renderer.ts +218 -70
  83. package/src/canvas/selection.ts +52 -210
  84. package/src/canvas/text-geometry.ts +311 -0
  85. package/src/canvas/text.ts +43 -9
  86. package/src/canvas/types.ts +14 -1
  87. package/src/canvas/variants.ts +24 -5
  88. package/src/dom/a11y-styles.ts +65 -0
  89. package/src/dom/applicators/aria.ts +41 -0
  90. package/src/dom/applicators/events.ts +38 -7
  91. package/src/dom/applicators/index.ts +5 -0
  92. package/src/dom/components/hypenapp.ts +244 -272
  93. package/src/dom/components/index.ts +7 -0
  94. package/src/dom/components/spinner.ts +31 -13
  95. package/src/dom/components/tabs.ts +76 -0
  96. package/src/dom/components/visuallyhidden.ts +30 -0
  97. package/src/dom/operability.ts +312 -0
  98. package/src/dom/renderer.ts +455 -63
  99. package/src/dom/route-focus.ts +98 -0
  100. package/src/dom/semantics.ts +199 -0
  101. package/dist/canvas/input.d.ts +0 -76
  102. package/dist/canvas/input.js +0 -207
  103. package/dist/canvas/input.js.map +0 -1
  104. package/src/canvas/input.ts +0 -251
@@ -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 { getScrollAwareBounds } from "./scroll.js";
10
- import { resolveEventAction } from "./props.js";
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 focusChangeHandler:
43
- | ((next: VirtualNode | null, prev: VirtualNode | null) => void)
44
- | null = null;
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
- * Subscribe to focus changes. Used by the renderer to mount the
71
- * `InputOverlay` HTML element when an Input/Textarea gains focus and
72
- * unmount it when focus moves away. Without this hookup the canvas
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
- setFocusChangeHandler(
77
- fn: ((next: VirtualNode | null, prev: VirtualNode | null) => void) | null,
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.focusChangeHandler = fn;
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(node: VirtualNode, point: Point): VirtualNode | null {
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 bounds = getScrollAwareBounds(node);
141
- if (!bounds) return null;
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
- const radius = node.layout.border.radius;
160
- if (isPointInRoundedRect(point, bounds, radius)) {
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
- // Update focus
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.setFocus(node);
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.setFocus(null);
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
- // Engine emits event applicators in camelCase (`onClick`, `onMouseEnter`).
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
+ }
@@ -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,