@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
@@ -5,10 +5,14 @@
5
5
  * triple-click-line, and Ctrl/Cmd+C copy for canvas-rendered text.
6
6
  */
7
7
 
8
- import type { VirtualNode, Point, FontStyle, TextMetrics } from "./types.js";
9
- import { getScrollAwareBounds } from "./scroll.js";
10
- import { measureText } from "./text.js";
11
- import { createFontString } from "./utils.js";
8
+ import type { VirtualNode, Point } from "./types.js";
9
+ import {
10
+ SELECTION_HIGHLIGHT_COLOR,
11
+ nodeTextGeometry,
12
+ pointToOffset,
13
+ rangeToRects,
14
+ resolveNodeText,
15
+ } from "./text-geometry.js";
12
16
 
13
17
  // ---------------------------------------------------------------------------
14
18
  // Types
@@ -36,12 +40,6 @@ export interface SelectionRect {
36
40
  height: number;
37
41
  }
38
42
 
39
- // ---------------------------------------------------------------------------
40
- // Selection state
41
- // ---------------------------------------------------------------------------
42
-
43
- const HIGHLIGHT_COLOR = "rgba(59, 130, 246, 0.35)";
44
-
45
43
  // ---------------------------------------------------------------------------
46
44
  // SelectionManager
47
45
  // ---------------------------------------------------------------------------
@@ -202,7 +200,7 @@ export class SelectionManager {
202
200
  private selectWord(pos: TextPosition): void {
203
201
  const node = this.findNodeById(pos.nodeId);
204
202
  if (!node) return;
205
- const text = resolveText(node);
203
+ const text = resolveNodeText(node);
206
204
  const start = wordBoundaryBefore(text, pos.offset);
207
205
  const end = wordBoundaryAfter(text, pos.offset);
208
206
  this.selection = {
@@ -214,7 +212,7 @@ export class SelectionManager {
214
212
  private selectAll(nodeId: string): void {
215
213
  const node = this.findNodeById(nodeId);
216
214
  if (!node) return;
217
- const text = resolveText(node);
215
+ const text = resolveNodeText(node);
218
216
  this.selection = {
219
217
  anchor: { nodeId, offset: 0 },
220
218
  focus: { nodeId, offset: text.length },
@@ -225,7 +223,7 @@ export class SelectionManager {
225
223
  if (!this.selection) return null;
226
224
  const node = this.findNodeById(this.selection.anchor.nodeId);
227
225
  if (!node) return null;
228
- const text = resolveText(node);
226
+ const text = resolveNodeText(node);
229
227
  const [start, end] = normalizeRange(this.selection);
230
228
  return text.slice(start, end);
231
229
  }
@@ -245,29 +243,50 @@ export class SelectionManager {
245
243
  private hitTestTextNode(
246
244
  node: VirtualNode,
247
245
  point: Point,
246
+ scrollX: number = 0,
247
+ scrollY: number = 0,
248
248
  ): TextPosition | null {
249
249
  if (!node.visible || !node.layout) return null;
250
250
 
251
+ const layout = node.layout;
252
+ const x = layout.x - scrollX;
253
+ const y = layout.y - scrollY;
254
+ const inBounds =
255
+ point.x >= x && point.x <= x + layout.width &&
256
+ point.y >= y && point.y <= y + layout.height;
257
+
258
+ // A clipping container confines its subtree to its own box — when the
259
+ // point is outside, no descendant text can be visible under it.
260
+ if (!inBounds) {
261
+ const overflow = node.props.overflow;
262
+ if (overflow === "hidden" || overflow === "scroll" || overflow === "auto") {
263
+ return null;
264
+ }
265
+ }
266
+
267
+ const childScrollX = scrollX + (node.scrollState?.scrollX ?? 0);
268
+ const childScrollY = scrollY + (node.scrollState?.scrollY ?? 0);
269
+
251
270
  // Check children first (front-to-back)
252
271
  for (let i = node.children.length - 1; i >= 0; i--) {
253
- const result = this.hitTestTextNode(node.children[i], point);
272
+ const result = this.hitTestTextNode(node.children[i], point, childScrollX, childScrollY);
254
273
  if (result) return result;
255
274
  }
256
275
 
257
276
  // Only text nodes are selectable
258
277
  if (node.type !== "text") return null;
259
-
260
- const bounds = getScrollAwareBounds(node);
261
- if (!bounds) return null;
262
- if (
263
- point.x < bounds.x || point.x > bounds.x + bounds.width ||
264
- point.y < bounds.y || point.y > bounds.y + bounds.height
265
- ) {
266
- return null;
267
- }
278
+ if (!inBounds) return null;
268
279
 
269
280
  // Map point to character offset within this text node
270
- const offset = pointToOffset(this.canvas, node, bounds, point);
281
+ const ctx = this.canvas.getContext("2d");
282
+ if (!ctx) return null;
283
+ const geometry = nodeTextGeometry(
284
+ node,
285
+ resolveNodeText(node),
286
+ x + layout.contentX,
287
+ y + layout.contentY,
288
+ );
289
+ const offset = pointToOffset(ctx, geometry, point);
271
290
  return { nodeId: node.id, offset };
272
291
  }
273
292
 
@@ -285,10 +304,17 @@ export class SelectionManager {
285
304
  const [start, end] = normalizeRange(this.selection);
286
305
  if (start === end) return; // caret only, no highlight
287
306
 
288
- const rects = getSelectionRects(ctx, node, start, end);
307
+ const layout = node.layout!;
308
+ const geometry = nodeTextGeometry(
309
+ node,
310
+ resolveNodeText(node),
311
+ layout.x + layout.contentX,
312
+ layout.y + layout.contentY,
313
+ );
314
+ const rects = rangeToRects(ctx, geometry, start, end);
289
315
 
290
316
  ctx.save();
291
- ctx.fillStyle = HIGHLIGHT_COLOR;
317
+ ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
292
318
  for (const r of rects) {
293
319
  ctx.fillRect(r.x, r.y, r.width, r.height);
294
320
  }
@@ -321,16 +347,6 @@ export class SelectionManager {
321
347
  // Pure helpers
322
348
  // ---------------------------------------------------------------------------
323
349
 
324
- function resolveText(node: VirtualNode): string {
325
- const raw = node.props[0] || node.props.text || "";
326
- let text = String(raw);
327
- const tt = node.props.textTransform || "none";
328
- if (tt === "uppercase") text = text.toUpperCase();
329
- else if (tt === "lowercase") text = text.toLowerCase();
330
- else if (tt === "capitalize") text = text.replace(/\b\w/g, (c) => c.toUpperCase());
331
- return text;
332
- }
333
-
334
350
  function normalizeRange(sel: TextSelection): [number, number] {
335
351
  const a = sel.anchor.offset;
336
352
  const b = sel.focus.offset;
@@ -364,180 +380,6 @@ function findById(node: VirtualNode, id: string): VirtualNode | null {
364
380
  return null;
365
381
  }
366
382
 
367
- // ---------------------------------------------------------------------------
368
- // Geometry: map a canvas point to a character offset
369
- // ---------------------------------------------------------------------------
370
-
371
- /**
372
- * Given a point inside a text node's bounds, return the character index
373
- * that the point falls on.
374
- */
375
- function pointToOffset(
376
- canvas: HTMLCanvasElement,
377
- node: VirtualNode,
378
- bounds: { x: number; y: number; width: number; height: number },
379
- point: Point,
380
- ): number {
381
- const ctx = canvas.getContext("2d");
382
- if (!ctx) return 0;
383
-
384
- const props = node.props;
385
- const text = resolveText(node);
386
- const layout = node.layout!;
387
- const fontSize = parseFloat(props.fontSize) || 16;
388
- const fontWeight = props.fontWeight || "normal";
389
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
390
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
391
- const textAlign = props.textAlign || "left";
392
-
393
- const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
394
- const font = createFontString(fontSize, fontWeight, fontFamily);
395
-
396
- ctx.save();
397
- ctx.font = font;
398
-
399
- const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
400
- const contentX = bounds.x + layout.contentX;
401
- const contentY = bounds.y + layout.contentY;
402
-
403
- // Find which line the point falls on
404
- const relY = point.y - contentY;
405
- let lineIndex = Math.floor(relY / metrics.lineHeight);
406
- lineIndex = Math.max(0, Math.min(lineIndex, metrics.lines.length - 1));
407
-
408
- // Calculate character offset before this line
409
- let charsBefore = 0;
410
- for (let i = 0; i < lineIndex; i++) {
411
- charsBefore += metrics.lines[i].length;
412
- // Account for space/newline between lines (word wrap eats the space)
413
- if (i < metrics.lines.length - 1) {
414
- const nextLineStart = text.indexOf(metrics.lines[i + 1], charsBefore);
415
- if (nextLineStart > charsBefore) charsBefore = nextLineStart;
416
- }
417
- }
418
-
419
- const line = metrics.lines[lineIndex];
420
-
421
- // Calculate line X offset (for text-align)
422
- let lineX = contentX;
423
- if (textAlign === "center") {
424
- const lw = ctx.measureText(line).width;
425
- lineX = contentX + (layout.contentWidth - lw) / 2;
426
- } else if (textAlign === "right") {
427
- const lw = ctx.measureText(line).width;
428
- lineX = contentX + layout.contentWidth - lw;
429
- }
430
-
431
- // Binary search for the character at point.x
432
- const relX = point.x - lineX;
433
- let best = 0;
434
- for (let i = 0; i <= line.length; i++) {
435
- const w = ctx.measureText(line.slice(0, i)).width;
436
- if (w <= relX) {
437
- best = i;
438
- } else {
439
- // Check if we're closer to this char or the previous
440
- const prevW = ctx.measureText(line.slice(0, i - 1)).width;
441
- if (relX - prevW < w - relX) {
442
- best = i - 1;
443
- } else {
444
- best = i;
445
- }
446
- break;
447
- }
448
- }
449
-
450
- ctx.restore();
451
- return charsBefore + best;
452
- }
453
-
454
- // ---------------------------------------------------------------------------
455
- // Geometry: compute highlight rectangles for a selection range
456
- // ---------------------------------------------------------------------------
457
-
458
- /**
459
- * Return a list of rectangles to paint as the selection highlight for
460
- * characters [start, end) within a text node.
461
- */
462
- function getSelectionRects(
463
- ctx: CanvasRenderingContext2D,
464
- node: VirtualNode,
465
- start: number,
466
- end: number,
467
- ): SelectionRect[] {
468
- const props = node.props;
469
- const layout = node.layout!;
470
- const text = resolveText(node);
471
- const fontSize = parseFloat(props.fontSize) || 16;
472
- const fontWeight = props.fontWeight || "normal";
473
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
474
- const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
475
- const textAlign = props.textAlign || "left";
476
-
477
- const fontStyle: FontStyle = { fontSize, fontWeight, fontFamily, lineHeight };
478
- const font = createFontString(fontSize, fontWeight, fontFamily);
479
-
480
- ctx.save();
481
- ctx.font = font;
482
-
483
- const metrics = measureText(ctx, text, fontStyle, layout.contentWidth);
484
- const x = layout.x + layout.contentX;
485
- const y = layout.y + layout.contentY;
486
-
487
- const rects: SelectionRect[] = [];
488
-
489
- // Map lines to character ranges
490
- let charOffset = 0;
491
- for (let i = 0; i < metrics.lines.length; i++) {
492
- const line = metrics.lines[i];
493
- const lineStart = charOffset;
494
- const lineEnd = charOffset + line.length;
495
-
496
- // Find actual position in original text for this line
497
- if (i > 0) {
498
- const idx = text.indexOf(line, charOffset);
499
- if (idx > charOffset) charOffset = idx;
500
- }
501
- const actualLineStart = charOffset;
502
- const actualLineEnd = charOffset + line.length;
503
-
504
- // Does this line overlap with the selection?
505
- if (actualLineEnd <= start || actualLineStart >= end) {
506
- charOffset = actualLineEnd;
507
- continue;
508
- }
509
-
510
- // Clamp selection to this line
511
- const selStart = Math.max(0, start - actualLineStart);
512
- const selEnd = Math.min(line.length, end - actualLineStart);
513
-
514
- // Calculate line X offset (text-align)
515
- let lineX = x;
516
- const lineWidth = ctx.measureText(line).width;
517
- if (textAlign === "center") {
518
- lineX = x + (layout.contentWidth - lineWidth) / 2;
519
- } else if (textAlign === "right") {
520
- lineX = x + layout.contentWidth - lineWidth;
521
- }
522
-
523
- // Measure selection bounds within this line
524
- const startX = ctx.measureText(line.slice(0, selStart)).width;
525
- const endX = ctx.measureText(line.slice(0, selEnd)).width;
526
-
527
- rects.push({
528
- x: lineX + startX,
529
- y: y + i * metrics.lineHeight,
530
- width: endX - startX,
531
- height: metrics.lineHeight,
532
- });
533
-
534
- charOffset = actualLineEnd;
535
- }
536
-
537
- ctx.restore();
538
- return rects;
539
- }
540
-
541
383
  // ---------------------------------------------------------------------------
542
384
  // Clipboard fallback
543
385
  // ---------------------------------------------------------------------------
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Text Geometry
3
+ *
4
+ * Pure point↔offset↔rect math shared by static-text selection
5
+ * (SelectionManager), input caret placement, and selection/caret painting.
6
+ * Everything is parameterized by an explicit {@link TextGeometry} — no
7
+ * node-type assumptions — so the same math serves Text, Input, and Textarea.
8
+ *
9
+ * Font sizes go through `cssLengthToPx` (matching paint.ts) so geometry
10
+ * lines up with painted glyphs even for `rem`-valued props.
11
+ */
12
+
13
+ import type { FontStyle, Point, Rectangle, VirtualNode } from "./types.js";
14
+ import { measureText } from "./text.js";
15
+ import { createFontString, cssLengthToPx } from "./utils.js";
16
+
17
+ /** Selection highlight color shared by static text and input painting. */
18
+ export const SELECTION_HIGHLIGHT_COLOR = "rgba(59, 130, 246, 0.35)";
19
+
20
+ export interface TextGeometry {
21
+ /** Already-resolved text (node text or input value, post-transform). */
22
+ text: string;
23
+ font: FontStyle;
24
+ textAlign: "left" | "center" | "right";
25
+ verticalAlign: "top" | "middle" | "bottom";
26
+ /** Content-box origin in the coordinate space of queried points / returned rects. */
27
+ contentX: number;
28
+ contentY: number;
29
+ contentWidth: number;
30
+ contentHeight: number;
31
+ /** Width used for line wrapping; undefined = single line (no wrap). */
32
+ wrapWidth?: number;
33
+ }
34
+
35
+ /**
36
+ * Resolve a node's displayed text, applying `textTransform` the same way
37
+ * paint does — geometry must measure what is actually drawn.
38
+ */
39
+ export function resolveNodeText(node: VirtualNode): string {
40
+ const raw = node.props[0] || node.props.text || "";
41
+ let text = String(raw);
42
+ const tt = node.props.textTransform || "none";
43
+ if (tt === "uppercase") text = text.toUpperCase();
44
+ else if (tt === "lowercase") text = text.toLowerCase();
45
+ else if (tt === "capitalize") text = text.replace(/\b\w/g, (c) => c.toUpperCase());
46
+ return text;
47
+ }
48
+
49
+ /** Font style from node props with paint.ts parity defaults. */
50
+ export function nodeFontStyle(props: Record<string, any>): FontStyle {
51
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
52
+ return {
53
+ fontSize,
54
+ fontWeight: props.fontWeight || "normal",
55
+ fontFamily: props.fontFamily || "system-ui, sans-serif",
56
+ lineHeight: cssLengthToPx(props.lineHeight) ?? fontSize * 1.2,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Build a TextGeometry for a node whose content box starts at
62
+ * (contentX, contentY) in the caller's coordinate space. Wrapping and
63
+ * vertical alignment are caller decisions: static text wraps at content
64
+ * width and is top-aligned (paintText parity); single-line inputs don't
65
+ * wrap and are middle-aligned (paintInput parity).
66
+ */
67
+ export function nodeTextGeometry(
68
+ node: VirtualNode,
69
+ text: string,
70
+ contentX: number,
71
+ contentY: number,
72
+ opts?: { verticalAlign?: TextGeometry["verticalAlign"]; wrap?: boolean },
73
+ ): TextGeometry {
74
+ const layout = node.layout!;
75
+ return {
76
+ text,
77
+ font: nodeFontStyle(node.props),
78
+ textAlign: (node.props.textAlign || "left") as TextGeometry["textAlign"],
79
+ verticalAlign: opts?.verticalAlign ?? "top",
80
+ contentX,
81
+ contentY,
82
+ contentWidth: layout.contentWidth,
83
+ contentHeight: layout.contentHeight,
84
+ wrapWidth: (opts?.wrap ?? true) ? layout.contentWidth : undefined,
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Character range each wrapped line covers in the original text. Word wrap
90
+ * eats the separator between lines, so line N+1's start is re-anchored with
91
+ * indexOf — same bookkeeping the selection code has always used.
92
+ */
93
+ export interface LineRange {
94
+ start: number;
95
+ end: number;
96
+ line: string;
97
+ }
98
+
99
+ export function lineRanges(text: string, lines: string[]): LineRange[] {
100
+ const ranges: LineRange[] = [];
101
+ let charOffset = 0;
102
+ for (let i = 0; i < lines.length; i++) {
103
+ if (i > 0) {
104
+ if (lines[i] === "") {
105
+ // A blank line (from a hard "\n\n" or a trailing Enter) can't be
106
+ // re-anchored with indexOf — it sits just past its newline.
107
+ if (text[charOffset] === "\n") charOffset += 1;
108
+ } else {
109
+ const idx = text.indexOf(lines[i], charOffset);
110
+ if (idx > charOffset) charOffset = idx;
111
+ }
112
+ }
113
+ ranges.push({ start: charOffset, end: charOffset + lines[i].length, line: lines[i] });
114
+ charOffset += lines[i].length;
115
+ }
116
+ return ranges;
117
+ }
118
+
119
+ /**
120
+ * Line layout for geometry purposes. Wrapping goes through `measureText`
121
+ * (pretext-aware) per hard-break paragraph. Single-line text (`wrapWidth`
122
+ * undefined) deliberately does NOT: line-layout engines strip trailing
123
+ * whitespace at line ends, which would pin the caret before a just-typed
124
+ * space until the next visible character arrives. A single-line input's
125
+ * "line" is the raw text, spaces and all — `ctx.measureText` advances
126
+ * include them.
127
+ *
128
+ * For wrapped text, each paragraph's stripped whitespace tail is
129
+ * re-attached to its last line for the same reason — a textarea caret must
130
+ * sit after a just-typed space, including one typed right before a newline.
131
+ * (Blank lines from "\n" come from measureText itself.)
132
+ */
133
+ function layoutLines(
134
+ ctx: CanvasRenderingContext2D,
135
+ g: TextGeometry,
136
+ ): { lines: string[]; lineHeight: number; height: number } {
137
+ const lineHeight = g.font.lineHeight || g.font.fontSize * 1.2;
138
+
139
+ if (g.wrapWidth === undefined) {
140
+ return { lines: [g.text], lineHeight, height: lineHeight };
141
+ }
142
+
143
+ const lines: string[] = [];
144
+ for (const paragraph of g.text.split("\n")) {
145
+ if (paragraph === "") {
146
+ lines.push("");
147
+ continue;
148
+ }
149
+ const m = measureText(ctx, paragraph, g.font, g.wrapWidth);
150
+ const paragraphLines = [...m.lines];
151
+ // Re-attach the paragraph's trailing whitespace (stripped by wrap
152
+ // layout) so the caret can sit after it.
153
+ const ranges = lineRanges(paragraph, paragraphLines);
154
+ const lastEnd = ranges.length > 0 ? ranges[ranges.length - 1].end : 0;
155
+ const tail = paragraph.slice(lastEnd);
156
+ if (tail.length > 0 && tail.trim() === "") {
157
+ paragraphLines[paragraphLines.length - 1] += tail;
158
+ }
159
+ lines.push(...paragraphLines);
160
+ }
161
+ if (lines.length === 0) lines.push("");
162
+
163
+ return { lines, lineHeight, height: lines.length * lineHeight };
164
+ }
165
+
166
+ function alignedStartY(g: TextGeometry, totalHeight: number): number {
167
+ if (g.verticalAlign === "middle") return g.contentY + (g.contentHeight - totalHeight) / 2;
168
+ if (g.verticalAlign === "bottom") return g.contentY + g.contentHeight - totalHeight;
169
+ return g.contentY;
170
+ }
171
+
172
+ function alignedLineX(ctx: CanvasRenderingContext2D, g: TextGeometry, line: string): number {
173
+ if (g.textAlign === "center") {
174
+ return g.contentX + (g.contentWidth - ctx.measureText(line).width) / 2;
175
+ }
176
+ if (g.textAlign === "right") {
177
+ return g.contentX + g.contentWidth - ctx.measureText(line).width;
178
+ }
179
+ return g.contentX;
180
+ }
181
+
182
+ /**
183
+ * Map a point to the character offset it falls on.
184
+ */
185
+ export function pointToOffset(
186
+ ctx: CanvasRenderingContext2D,
187
+ g: TextGeometry,
188
+ point: Point,
189
+ ): number {
190
+ ctx.save();
191
+ ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
192
+
193
+ const metrics = layoutLines(ctx, g);
194
+ const startY = alignedStartY(g, metrics.height);
195
+
196
+ const relY = point.y - startY;
197
+ let lineIndex = Math.floor(relY / metrics.lineHeight);
198
+ lineIndex = Math.max(0, Math.min(lineIndex, metrics.lines.length - 1));
199
+
200
+ const ranges = lineRanges(g.text, metrics.lines);
201
+ const { start: lineStart, line } = ranges[lineIndex];
202
+
203
+ const relX = point.x - alignedLineX(ctx, g, line);
204
+
205
+ // Prefix widths are monotonic in prefix length, so bisect for the longest
206
+ // prefix that fits, then snap to whichever side of the boundary glyph is
207
+ // closer. A point left of the line start clamps to 0.
208
+ let best = 0;
209
+ if (relX > 0) {
210
+ let lo = 0;
211
+ let hi = line.length;
212
+ while (lo < hi) {
213
+ const mid = (lo + hi + 1) >> 1;
214
+ if (ctx.measureText(line.slice(0, mid)).width <= relX) {
215
+ lo = mid;
216
+ } else {
217
+ hi = mid - 1;
218
+ }
219
+ }
220
+ best = lo;
221
+ if (lo < line.length) {
222
+ const w = ctx.measureText(line.slice(0, lo)).width;
223
+ const nextW = ctx.measureText(line.slice(0, lo + 1)).width;
224
+ best = relX - w < nextW - relX ? lo : lo + 1;
225
+ }
226
+ }
227
+
228
+ ctx.restore();
229
+ return lineStart + best;
230
+ }
231
+
232
+ /**
233
+ * Rectangles covering characters [start, end) — one per wrapped line the
234
+ * range touches. Used for selection highlights.
235
+ */
236
+ export function rangeToRects(
237
+ ctx: CanvasRenderingContext2D,
238
+ g: TextGeometry,
239
+ start: number,
240
+ end: number,
241
+ ): Rectangle[] {
242
+ if (start >= end) return [];
243
+ ctx.save();
244
+ ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
245
+
246
+ const metrics = layoutLines(ctx, g);
247
+ const startY = alignedStartY(g, metrics.height);
248
+ const ranges = lineRanges(g.text, metrics.lines);
249
+
250
+ const rects: Rectangle[] = [];
251
+ for (let i = 0; i < ranges.length; i++) {
252
+ const { start: lineStart, end: lineEnd, line } = ranges[i];
253
+ if (lineEnd <= start || lineStart >= end) continue;
254
+
255
+ const selStart = Math.max(0, start - lineStart);
256
+ const selEnd = Math.min(line.length, end - lineStart);
257
+
258
+ const lineX = alignedLineX(ctx, g, line);
259
+ const fromX = ctx.measureText(line.slice(0, selStart)).width;
260
+ const toX = ctx.measureText(line.slice(0, selEnd)).width;
261
+
262
+ rects.push({
263
+ x: lineX + fromX,
264
+ y: startY + i * metrics.lineHeight,
265
+ width: toX - fromX,
266
+ height: metrics.lineHeight,
267
+ });
268
+ }
269
+
270
+ ctx.restore();
271
+ return rects;
272
+ }
273
+
274
+ /**
275
+ * Caret rectangle for a character offset. At a wrap boundary the caret
276
+ * sits at the end of the earlier line (matching browser end-of-line
277
+ * placement for soft wraps).
278
+ */
279
+ export function offsetToCaretRect(
280
+ ctx: CanvasRenderingContext2D,
281
+ g: TextGeometry,
282
+ offset: number,
283
+ ): Rectangle {
284
+ ctx.save();
285
+ ctx.font = createFontString(g.font.fontSize, g.font.fontWeight, g.font.fontFamily);
286
+
287
+ const metrics = layoutLines(ctx, g);
288
+ const startY = alignedStartY(g, metrics.height);
289
+ const ranges = lineRanges(g.text, metrics.lines);
290
+
291
+ const clamped = Math.max(0, Math.min(offset, g.text.length));
292
+ let lineIndex = ranges.length - 1;
293
+ for (let i = 0; i < ranges.length; i++) {
294
+ if (clamped <= ranges[i].end) {
295
+ lineIndex = i;
296
+ break;
297
+ }
298
+ }
299
+
300
+ const { start: lineStart, line } = ranges[lineIndex];
301
+ const col = Math.max(0, Math.min(clamped - lineStart, line.length));
302
+ const x = alignedLineX(ctx, g, line) + ctx.measureText(line.slice(0, col)).width;
303
+
304
+ ctx.restore();
305
+ return {
306
+ x,
307
+ y: startY + lineIndex * metrics.lineHeight,
308
+ width: 1,
309
+ height: metrics.lineHeight,
310
+ };
311
+ }