@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.
Files changed (229) hide show
  1. package/README.md +20 -13
  2. package/dist/canvas/accessibility.d.ts +118 -11
  3. package/dist/canvas/accessibility.js +470 -108
  4. package/dist/canvas/accessibility.js.map +1 -1
  5. package/dist/canvas/dispatch.d.ts +24 -0
  6. package/dist/canvas/dispatch.js +62 -0
  7. package/dist/canvas/dispatch.js.map +1 -0
  8. package/dist/canvas/editing.d.ts +178 -0
  9. package/dist/canvas/editing.js +590 -0
  10. package/dist/canvas/editing.js.map +1 -0
  11. package/dist/canvas/events.d.ts +28 -23
  12. package/dist/canvas/events.js +90 -103
  13. package/dist/canvas/events.js.map +1 -1
  14. package/dist/canvas/focus.d.ts +78 -0
  15. package/dist/canvas/focus.js +182 -0
  16. package/dist/canvas/focus.js.map +1 -0
  17. package/dist/canvas/index.d.ts +3 -2
  18. package/dist/canvas/index.js +3 -2
  19. package/dist/canvas/index.js.map +1 -1
  20. package/dist/canvas/layout.js +68 -98
  21. package/dist/canvas/layout.js.map +1 -1
  22. package/dist/canvas/paint.d.ts +16 -3
  23. package/dist/canvas/paint.js +449 -110
  24. package/dist/canvas/paint.js.map +1 -1
  25. package/dist/canvas/renderer.d.ts +28 -4
  26. package/dist/canvas/renderer.js +201 -81
  27. package/dist/canvas/renderer.js.map +1 -1
  28. package/dist/canvas/selection.js +31 -177
  29. package/dist/canvas/selection.js.map +1 -1
  30. package/dist/canvas/text-geometry.d.ts +72 -0
  31. package/dist/canvas/text-geometry.js +244 -0
  32. package/dist/canvas/text-geometry.js.map +1 -0
  33. package/dist/canvas/text.js +44 -18
  34. package/dist/canvas/text.js.map +1 -1
  35. package/dist/canvas/types.d.ts +10 -1
  36. package/dist/canvas/utils.d.ts +0 -23
  37. package/dist/canvas/utils.js +3 -67
  38. package/dist/canvas/utils.js.map +1 -1
  39. package/dist/canvas/variants.d.ts +52 -0
  40. package/dist/canvas/variants.js +156 -0
  41. package/dist/canvas/variants.js.map +1 -0
  42. package/dist/dom/a11y-styles.d.ts +20 -0
  43. package/dist/dom/a11y-styles.js +61 -0
  44. package/dist/dom/a11y-styles.js.map +1 -0
  45. package/dist/dom/applicators/aria.d.ts +19 -0
  46. package/dist/dom/applicators/aria.js +36 -0
  47. package/dist/dom/applicators/aria.js.map +1 -0
  48. package/dist/dom/applicators/events.d.ts +7 -0
  49. package/dist/dom/applicators/events.js +36 -8
  50. package/dist/dom/applicators/events.js.map +1 -1
  51. package/dist/dom/applicators/font.d.ts +11 -6
  52. package/dist/dom/applicators/font.js +16 -27
  53. package/dist/dom/applicators/font.js.map +1 -1
  54. package/dist/dom/applicators/index.js +51 -45
  55. package/dist/dom/applicators/index.js.map +1 -1
  56. package/dist/dom/canvas/index.js +2 -2
  57. package/dist/dom/canvas/index.js.map +1 -1
  58. package/dist/dom/components/app.js +2 -2
  59. package/dist/dom/components/app.js.map +1 -1
  60. package/dist/dom/components/audio.js +2 -2
  61. package/dist/dom/components/audio.js.map +1 -1
  62. package/dist/dom/components/avatar.js +3 -4
  63. package/dist/dom/components/avatar.js.map +1 -1
  64. package/dist/dom/components/badge.js +3 -4
  65. package/dist/dom/components/badge.js.map +1 -1
  66. package/dist/dom/components/button.js +2 -2
  67. package/dist/dom/components/button.js.map +1 -1
  68. package/dist/dom/components/card.js +2 -2
  69. package/dist/dom/components/card.js.map +1 -1
  70. package/dist/dom/components/center.js +2 -2
  71. package/dist/dom/components/center.js.map +1 -1
  72. package/dist/dom/components/checkbox.js +4 -5
  73. package/dist/dom/components/checkbox.js.map +1 -1
  74. package/dist/dom/components/column.js +2 -2
  75. package/dist/dom/components/column.js.map +1 -1
  76. package/dist/dom/components/container.js +2 -2
  77. package/dist/dom/components/container.js.map +1 -1
  78. package/dist/dom/components/divider.js +2 -2
  79. package/dist/dom/components/divider.js.map +1 -1
  80. package/dist/dom/components/grid.js +10 -10
  81. package/dist/dom/components/grid.js.map +1 -1
  82. package/dist/dom/components/heading.js +4 -6
  83. package/dist/dom/components/heading.js.map +1 -1
  84. package/dist/dom/components/hypenapp.d.ts +25 -1
  85. package/dist/dom/components/hypenapp.js +215 -247
  86. package/dist/dom/components/hypenapp.js.map +1 -1
  87. package/dist/dom/components/icon.js +4 -5
  88. package/dist/dom/components/icon.js.map +1 -1
  89. package/dist/dom/components/image.js +2 -2
  90. package/dist/dom/components/image.js.map +1 -1
  91. package/dist/dom/components/index.d.ts +2 -2
  92. package/dist/dom/components/index.js +9 -2
  93. package/dist/dom/components/index.js.map +1 -1
  94. package/dist/dom/components/input.js +2 -2
  95. package/dist/dom/components/input.js.map +1 -1
  96. package/dist/dom/components/link.js +2 -2
  97. package/dist/dom/components/link.js.map +1 -1
  98. package/dist/dom/components/list.js +2 -2
  99. package/dist/dom/components/list.js.map +1 -1
  100. package/dist/dom/components/paragraph.js +3 -4
  101. package/dist/dom/components/paragraph.js.map +1 -1
  102. package/dist/dom/components/progressbar.js +3 -3
  103. package/dist/dom/components/progressbar.js.map +1 -1
  104. package/dist/dom/components/route.js +2 -2
  105. package/dist/dom/components/route.js.map +1 -1
  106. package/dist/dom/components/router.js +2 -2
  107. package/dist/dom/components/router.js.map +1 -1
  108. package/dist/dom/components/row.js +10 -10
  109. package/dist/dom/components/row.js.map +1 -1
  110. package/dist/dom/components/select.js +3 -4
  111. package/dist/dom/components/select.js.map +1 -1
  112. package/dist/dom/components/slider.js +2 -2
  113. package/dist/dom/components/slider.js.map +1 -1
  114. package/dist/dom/components/spacer.js +2 -2
  115. package/dist/dom/components/spacer.js.map +1 -1
  116. package/dist/dom/components/spinner.js +27 -13
  117. package/dist/dom/components/spinner.js.map +1 -1
  118. package/dist/dom/components/stack.js +10 -10
  119. package/dist/dom/components/stack.js.map +1 -1
  120. package/dist/dom/components/switch.js +5 -6
  121. package/dist/dom/components/switch.js.map +1 -1
  122. package/dist/dom/components/tabs.d.ts +27 -0
  123. package/dist/dom/components/tabs.js +69 -0
  124. package/dist/dom/components/tabs.js.map +1 -0
  125. package/dist/dom/components/text.js +4 -7
  126. package/dist/dom/components/text.js.map +1 -1
  127. package/dist/dom/components/textarea.js +2 -2
  128. package/dist/dom/components/textarea.js.map +1 -1
  129. package/dist/dom/components/video.js +2 -2
  130. package/dist/dom/components/video.js.map +1 -1
  131. package/dist/dom/components/visuallyhidden.d.ts +9 -0
  132. package/dist/dom/components/visuallyhidden.js +26 -0
  133. package/dist/dom/components/visuallyhidden.js.map +1 -0
  134. package/dist/dom/debug.js +6 -8
  135. package/dist/dom/debug.js.map +1 -1
  136. package/dist/dom/index.d.ts +0 -1
  137. package/dist/dom/index.js +0 -1
  138. package/dist/dom/index.js.map +1 -1
  139. package/dist/dom/operability.d.ts +100 -0
  140. package/dist/dom/operability.js +298 -0
  141. package/dist/dom/operability.js.map +1 -0
  142. package/dist/dom/renderer.d.ts +98 -5
  143. package/dist/dom/renderer.js +400 -66
  144. package/dist/dom/renderer.js.map +1 -1
  145. package/dist/dom/route-focus.d.ts +42 -0
  146. package/dist/dom/route-focus.js +88 -0
  147. package/dist/dom/route-focus.js.map +1 -0
  148. package/dist/dom/semantics.d.ts +35 -0
  149. package/dist/dom/semantics.js +184 -0
  150. package/dist/dom/semantics.js.map +1 -0
  151. package/dist/variants.d.ts +90 -0
  152. package/dist/variants.js +206 -0
  153. package/dist/variants.js.map +1 -0
  154. package/package.json +2 -2
  155. package/src/canvas/QUICKSTART.md +16 -11
  156. package/src/canvas/README.md +63 -34
  157. package/src/canvas/accessibility.ts +507 -115
  158. package/src/canvas/dispatch.ts +78 -0
  159. package/src/canvas/editing.ts +697 -0
  160. package/src/canvas/events.ts +104 -115
  161. package/src/canvas/focus.ts +216 -0
  162. package/src/canvas/index.ts +8 -9
  163. package/src/canvas/layout.ts +68 -103
  164. package/src/canvas/paint.ts +509 -109
  165. package/src/canvas/renderer.ts +239 -99
  166. package/src/canvas/selection.ts +52 -210
  167. package/src/canvas/text-geometry.ts +311 -0
  168. package/src/canvas/text.ts +45 -18
  169. package/src/canvas/types.ts +28 -1
  170. package/src/canvas/utils.ts +3 -69
  171. package/src/canvas/variants.ts +172 -0
  172. package/src/dom/README.md +8 -7
  173. package/src/dom/a11y-styles.ts +65 -0
  174. package/src/dom/applicators/aria.ts +41 -0
  175. package/src/dom/applicators/events.ts +39 -7
  176. package/src/dom/applicators/font.ts +16 -29
  177. package/src/dom/applicators/index.ts +61 -46
  178. package/src/dom/canvas/index.ts +2 -2
  179. package/src/dom/components/app.ts +2 -2
  180. package/src/dom/components/audio.ts +2 -2
  181. package/src/dom/components/avatar.ts +3 -4
  182. package/src/dom/components/badge.ts +3 -4
  183. package/src/dom/components/button.ts +2 -2
  184. package/src/dom/components/card.ts +2 -2
  185. package/src/dom/components/center.ts +2 -2
  186. package/src/dom/components/checkbox.ts +4 -5
  187. package/src/dom/components/column.ts +2 -2
  188. package/src/dom/components/container.ts +2 -2
  189. package/src/dom/components/divider.ts +2 -2
  190. package/src/dom/components/grid.ts +10 -10
  191. package/src/dom/components/heading.ts +4 -6
  192. package/src/dom/components/hypenapp.ts +246 -274
  193. package/src/dom/components/icon.ts +4 -5
  194. package/src/dom/components/image.ts +2 -2
  195. package/src/dom/components/index.ts +10 -3
  196. package/src/dom/components/input.ts +2 -2
  197. package/src/dom/components/link.ts +2 -2
  198. package/src/dom/components/list.ts +2 -2
  199. package/src/dom/components/paragraph.ts +3 -4
  200. package/src/dom/components/progressbar.ts +3 -3
  201. package/src/dom/components/route.ts +2 -2
  202. package/src/dom/components/router.ts +2 -2
  203. package/src/dom/components/row.ts +10 -10
  204. package/src/dom/components/select.ts +3 -4
  205. package/src/dom/components/slider.ts +2 -2
  206. package/src/dom/components/spacer.ts +2 -2
  207. package/src/dom/components/spinner.ts +34 -16
  208. package/src/dom/components/stack.ts +10 -10
  209. package/src/dom/components/switch.ts +5 -6
  210. package/src/dom/components/tabs.ts +76 -0
  211. package/src/dom/components/text.ts +4 -7
  212. package/src/dom/components/textarea.ts +2 -2
  213. package/src/dom/components/video.ts +2 -2
  214. package/src/dom/components/visuallyhidden.ts +30 -0
  215. package/src/dom/debug.ts +6 -8
  216. package/src/dom/index.ts +0 -8
  217. package/src/dom/operability.ts +312 -0
  218. package/src/dom/renderer.ts +457 -66
  219. package/src/dom/route-focus.ts +98 -0
  220. package/src/dom/semantics.ts +199 -0
  221. package/src/variants.ts +249 -0
  222. package/dist/canvas/input.d.ts +0 -76
  223. package/dist/canvas/input.js +0 -207
  224. package/dist/canvas/input.js.map +0 -1
  225. package/dist/client.d.ts +0 -36
  226. package/dist/client.js +0 -47
  227. package/dist/client.js.map +0 -1
  228. package/src/canvas/input.ts +0 -251
  229. package/src/client.ts +0 -88
@@ -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 { 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 {
@@ -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 focusChangeHandler:
28
- | ((next: VirtualNode | null, prev: VirtualNode | null) => void)
29
- | 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 };
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
- * Subscribe to focus changes. Used by the renderer to mount the
56
- * `InputOverlay` HTML element when an Input/Textarea gains focus and
57
- * unmount it when focus moves away. Without this hookup the canvas
58
- * Input painted as a pretty pill but a click on it did nothing — the
59
- * overlay was constructed but never invoked.
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
- setFocusChangeHandler(
62
- fn: ((next: VirtualNode | null, prev: VirtualNode | null) => void) | null,
74
+ setEditablePointerHandler(
75
+ fn: ((node: VirtualNode, point: Point) => void) | null,
63
76
  ): void {
64
- this.focusChangeHandler = fn;
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(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 {
123
142
  if (!node.visible || !node.layout) return null;
124
143
 
125
- const bounds = getScrollAwareBounds(node);
126
- 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);
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
- const radius = node.layout.border.radius;
145
- if (isPointInRoundedRect(point, bounds, radius)) {
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
- // 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.
222
279
  if (node && node.focusable) {
223
- 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);
224
284
  } else {
225
- this.setFocus(null);
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
- // Engine emits event applicators in camelCase (`onClick`, `onChange`). The
387
- // older flat form `onclick` is still accepted. After prop normalisation the
388
- // value is either a string (action name) or an object carrying an action
389
- // name at `"0"` plus an auxiliary payload (e.g. `{ "0": "@router.push",
390
- // to: "/notifications" }` for `@router.push(to: "/notifications")`).
391
- const camel = `on${eventType.charAt(0).toUpperCase()}${eventType.slice(1)}`;
392
- let spec: unknown = node.props[camel] ?? node.props[`on${eventType}`] ?? node.props[eventType];
393
-
394
- // Actionable components fall back to the bare `action` prop on click.
395
- if (spec == null && eventType === "click") {
396
- spec = node.props.action;
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.canvas.removeEventListener("keydown", this.boundOnKeyDown);
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
+ }
@@ -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,