@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,11 +4,17 @@
4
4
  * Drawing virtual nodes to canvas
5
5
  */
6
6
 
7
- import type { VirtualNode, PainterFunction } from "./types.js";
8
- import { renderText } from "./text.js";
7
+ import type { VirtualNode, PainterFunction, Rectangle } from "./types.js";
8
+ import { measureText, renderText } from "./text.js";
9
9
  import { ScrollManager, isScrollable } from "./scroll.js";
10
10
  import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
11
11
  import type { SelectionManager } from "./selection.js";
12
+ import type { TextEditController } from "./editing.js";
13
+ import {
14
+ SELECTION_HIGHLIGHT_COLOR,
15
+ offsetToCaretRect,
16
+ rangeToRects,
17
+ } from "./text-geometry.js";
12
18
  import { cssLengthToPx } from "./utils.js";
13
19
 
14
20
  /**
@@ -20,6 +26,16 @@ export function setSelectionManager(mgr: SelectionManager | null): void {
20
26
  activeSelectionManager = mgr;
21
27
  }
22
28
 
29
+ /**
30
+ * Module-level reference to the active TextEditController so paintInput can
31
+ * render the edited value, selection highlight, composition underline, and
32
+ * blinking caret. Same late-binding pattern as the SelectionManager hook.
33
+ */
34
+ let activeTextEditor: TextEditController | null = null;
35
+ export function setTextEditor(editor: TextEditController | null): void {
36
+ activeTextEditor = editor;
37
+ }
38
+
23
39
  /**
24
40
  * Custom painters registry
25
41
  */
@@ -33,23 +49,170 @@ export function registerPainter(type: string, painter: PainterFunction): void {
33
49
  }
34
50
 
35
51
  /**
36
- * Paint a virtual node and its children
52
+ * Per-font character advance widths, used by the manual letter-spacing path
53
+ * so each glyph is shaped once per font instead of once per frame.
37
54
  */
38
- export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
39
- if (!node.visible || !node.layout) return;
55
+ const charAdvanceCache = new Map<string, Map<string, number>>();
56
+ const MAX_CHAR_ADVANCE_FONTS = 32;
40
57
 
41
- ctx.save();
58
+ /**
59
+ * Clear cached per-character advances. Must be called when web fonts finish
60
+ * loading: the cache is keyed by CSS font string, which is identical before
61
+ * and after a webfont replaces its fallback, so advances measured against
62
+ * the fallback font would otherwise poison letter-spaced rendering
63
+ * permanently.
64
+ */
65
+ export function clearCharAdvanceCache(): void {
66
+ charAdvanceCache.clear();
67
+ }
68
+
69
+ function charAdvance(ctx: CanvasRenderingContext2D, font: string, ch: string): number {
70
+ let perFont = charAdvanceCache.get(font);
71
+ if (!perFont) {
72
+ perFont = new Map();
73
+ charAdvanceCache.set(font, perFont);
74
+ evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
75
+ }
76
+ let width = perFont.get(ch);
77
+ if (width === undefined) {
78
+ width = ctx.measureText(ch).width;
79
+ perFont.set(ch, width);
80
+ }
81
+ return width;
82
+ }
83
+
84
+ /**
85
+ * Component types whose painters set sticky canvas state (textAlign,
86
+ * textBaseline, lineCap, shadows, …) that must not leak to siblings — they
87
+ * need a scoping save/restore pair. Containers, text, and spacers (and
88
+ * every unknown type, which routes to paintContainer) only touch state each
89
+ * draw call re-sets before use, so they paint without one.
90
+ */
91
+ const STATE_UNSAFE_TYPES = new Set([
92
+ "button",
93
+ "input",
94
+ "textarea",
95
+ "image",
96
+ "divider",
97
+ "separator",
98
+ "checkbox",
99
+ "radio",
100
+ "switch",
101
+ "toggle",
102
+ "slider",
103
+ "progress",
104
+ "progressbar",
105
+ "spinner",
106
+ "loading",
107
+ "card",
108
+ "badge",
109
+ "avatar",
110
+ "icon",
111
+ "link",
112
+ ]);
113
+
114
+ /** Whether any transform-related prop is present on the node. */
115
+ function hasTransformProps(props: Record<string, any>): boolean {
116
+ return (
117
+ props.transform !== undefined ||
118
+ props.translateX !== undefined ||
119
+ props.translateY !== undefined ||
120
+ props.rotate !== undefined ||
121
+ props.scale !== undefined ||
122
+ props.scaleX !== undefined ||
123
+ props.scaleY !== undefined ||
124
+ props.skew !== undefined ||
125
+ props.skewX !== undefined ||
126
+ props.skewY !== undefined
127
+ );
128
+ }
129
+
130
+ /** Extra pixels a node's painting may reach beyond its layout box. */
131
+ const CULL_SLACK = 8;
132
+
133
+ /** How far a shadow can bleed outside the box it is attached to. */
134
+ function shadowExtent(shadow: any): number {
135
+ if (typeof shadow === "string") {
136
+ const parts = shadow.trim().split(/\s+/);
137
+ const ox = Math.abs(parseFloat(parts[0])) || 0;
138
+ const oy = Math.abs(parseFloat(parts[1])) || 0;
139
+ const blur = Math.abs(parseFloat(parts[2])) || 0;
140
+ return Math.max(ox, oy) + blur;
141
+ }
142
+ if (shadow && typeof shadow === "object") {
143
+ return (
144
+ Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
145
+ Math.abs(shadow.blur || 0)
146
+ );
147
+ }
148
+ return 0;
149
+ }
150
+
151
+ /**
152
+ * Whether the whole subtree rooted at `node` can be skipped when repainting
153
+ * only `cull`. Conservative: nodes with transforms or custom painters are
154
+ * never culled (they can draw anywhere), the box is expanded by shadow
155
+ * bleed + slack, and a node with children is only culled when it clips them.
156
+ */
157
+ function canCullSubtree(node: VirtualNode, cull: Rectangle): boolean {
158
+ const layout = node.layout!;
159
+ if (hasTransformProps(node.props)) return false;
160
+ if (customPainters.has(node.type.toLowerCase())) return false;
161
+
162
+ const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
163
+ const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
164
+ if (
165
+ layout.x - margin < cull.x + cull.width &&
166
+ layout.x + layout.width + margin > cull.x &&
167
+ layout.y - margin < cull.y + cull.height &&
168
+ layout.y + layout.height + margin > cull.y
169
+ ) {
170
+ return false;
171
+ }
172
+
173
+ if (node.children.length === 0) return true;
174
+ const overflow = node.props.overflow;
175
+ return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
176
+ }
177
+
178
+ /**
179
+ * Paint a virtual node and its children.
180
+ *
181
+ * `cull` (optional) is the dirty region being repainted — subtrees that
182
+ * provably cannot reach it are skipped entirely.
183
+ */
184
+ export function paintNode(
185
+ ctx: CanvasRenderingContext2D,
186
+ node: VirtualNode,
187
+ cull?: Rectangle | null,
188
+ ): void {
189
+ if (!node.visible || !node.layout) return;
190
+ if (cull && canCullSubtree(node, cull)) return;
191
+
192
+ const type = node.type.toLowerCase();
193
+ const customPainter = customPainters.get(type);
194
+ const needsTransform = hasTransformProps(node.props);
195
+ const needsSave =
196
+ customPainter !== undefined ||
197
+ needsTransform ||
198
+ node.opacity < 1 ||
199
+ STATE_UNSAFE_TYPES.has(type);
200
+
201
+ if (needsSave) {
202
+ ctx.save();
42
203
 
43
- // Apply transforms
44
- applyTransforms(ctx, node);
204
+ // Apply transforms
205
+ if (needsTransform) {
206
+ applyTransforms(ctx, node);
207
+ }
45
208
 
46
- // Apply opacity
47
- if (node.opacity < 1) {
48
- ctx.globalAlpha = node.opacity;
209
+ // Apply opacity
210
+ if (node.opacity < 1) {
211
+ ctx.globalAlpha = node.opacity;
212
+ }
49
213
  }
50
214
 
51
215
  // Check for custom painter
52
- const customPainter = customPainters.get(node.type.toLowerCase());
53
216
  if (customPainter) {
54
217
  customPainter(ctx, node);
55
218
  ctx.restore();
@@ -57,7 +220,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
57
220
  }
58
221
 
59
222
  // Default painting based on type
60
- switch (node.type.toLowerCase()) {
223
+ switch (type) {
61
224
  case "column":
62
225
  case "row":
63
226
  case "stack":
@@ -70,6 +233,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
70
233
  paintButton(ctx, node);
71
234
  break;
72
235
  case "input":
236
+ case "textarea":
73
237
  paintInput(ctx, node);
74
238
  break;
75
239
  case "image":
@@ -127,14 +291,26 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
127
291
  paintContainer(ctx, node);
128
292
  }
129
293
 
130
- ctx.restore();
294
+ if (needsSave) {
295
+ ctx.restore();
296
+ }
131
297
 
132
298
  // Apply scroll translation for scrollable containers
133
299
  const ss = node.scrollState;
300
+ let childCull = cull ?? null;
134
301
  if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
135
302
  ctx.save();
136
303
  ctx.translate(-ss.scrollX, -ss.scrollY);
137
304
  (node as any)._scrollRestore = true;
305
+ // Children paint in scrolled space — shift the cull region to match.
306
+ if (childCull) {
307
+ childCull = {
308
+ x: childCull.x + ss.scrollX,
309
+ y: childCull.y + ss.scrollY,
310
+ width: childCull.width,
311
+ height: childCull.height,
312
+ };
313
+ }
138
314
  }
139
315
 
140
316
  // Paint children -- use windowed rendering for large scrollable lists
@@ -158,14 +334,21 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
158
334
  // tree (Story uses this for its close button) gets covered by the
159
335
  // following in-flow Image. Push absolute kids to the end so they land
160
336
  // on top — same effect as `z-index: auto` painting absolute after flow.
161
- const flowKids: VirtualNode[] = [];
162
- const overlayKids: VirtualNode[] = [];
337
+ let hasOverlays = false;
163
338
  for (const child of childrenToPaint) {
164
- if (child.props.position === "absolute") overlayKids.push(child);
165
- else flowKids.push(child);
339
+ if (child.props.position === "absolute") {
340
+ hasOverlays = true;
341
+ continue;
342
+ }
343
+ paintNode(ctx, child, childCull);
344
+ }
345
+ if (hasOverlays) {
346
+ for (const child of childrenToPaint) {
347
+ if (child.props.position === "absolute") {
348
+ paintNode(ctx, child, childCull);
349
+ }
350
+ }
166
351
  }
167
- for (const child of flowKids) paintNode(ctx, child);
168
- for (const child of overlayKids) paintNode(ctx, child);
169
352
 
170
353
  // Undo scroll translation before drawing scrollbars
171
354
  if ((node as any)._scrollRestore) {
@@ -299,14 +482,15 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
299
482
  if (letterSpacing !== 0) {
300
483
  // Manual letter spacing
301
484
  ctx.fillStyle = color;
302
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
485
+ const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
486
+ ctx.font = font;
303
487
  ctx.textAlign = "left";
304
488
  ctx.textBaseline = "top";
305
489
 
306
490
  let currentX = x;
307
491
  for (let i = 0; i < text.length; i++) {
308
492
  ctx.fillText(text[i], currentX, y);
309
- currentX += ctx.measureText(text[i]).width;
493
+ currentX += charAdvance(ctx, font, text[i]);
310
494
  // Add letter spacing after each character except the last
311
495
  if (i < text.length - 1) {
312
496
  currentX += letterSpacing;
@@ -350,11 +534,18 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
350
534
  textOverflow
351
535
  );
352
536
 
353
- // Text decoration (need to set font again for measurement)
537
+ // Text decoration reuse the wrapped metrics renderText just resolved
538
+ // (cache hit) instead of re-shaping the raw, unwrapped string.
354
539
  if (textDecoration !== "none") {
355
- ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
356
- const textWidth = ctx.measureText(text).width;
357
- applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
540
+ const metrics = measureText(
541
+ ctx,
542
+ text,
543
+ { fontSize, fontWeight, fontFamily, lineHeight },
544
+ layout.contentWidth,
545
+ maxLines,
546
+ textOverflow,
547
+ );
548
+ applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
358
549
  }
359
550
  }
360
551
 
@@ -423,6 +614,18 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
423
614
  ctx.stroke();
424
615
  }
425
616
 
617
+ // Keyboard focus ring. Focus lives on the node's (unrendered) mirror
618
+ // element in the canvas fallback content, so the UA can't draw a ring at
619
+ // the painted box — paint one so keyboard users can see where they are.
620
+ if (node.focused) {
621
+ ctx.save();
622
+ ctx.strokeStyle = props.focusRingColor || "#007bff";
623
+ ctx.lineWidth = 2;
624
+ drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
625
+ ctx.stroke();
626
+ ctx.restore();
627
+ }
628
+
426
629
  // Paint children (typically Text)
427
630
  for (const child of node.children) {
428
631
  paintNode(ctx, child);
@@ -455,24 +658,76 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
455
658
  drawRoundedRect(ctx, x, y, width, height, radius);
456
659
  ctx.stroke();
457
660
 
458
- // Input value text
459
- const value = props.value || "";
661
+ const isSingleLine = node.type.toLowerCase() === "input";
662
+
663
+ // While editing, the mirror element (via TextEditController) is the value
664
+ // source — paint stays ahead of the engine's SetProp echo.
665
+ const edit = activeTextEditor?.getStateFor(node) ?? null;
666
+
667
+ const value = edit ? edit.value : (props.value || "");
460
668
  const placeholder = props.placeholder || "";
461
669
  const text = value || placeholder;
462
670
  const textColor = value ? (props.color || "#000000") : "#999999";
463
671
 
464
- if (text) {
465
- const fontSize = cssLengthToPx(props.fontSize) ?? 16;
466
- const fontWeight = props.fontWeight || "normal";
467
- const fontFamily = props.fontFamily || "system-ui, sans-serif";
468
- const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
672
+ const fontSize = cssLengthToPx(props.fontSize) ?? 16;
673
+ const fontWeight = props.fontWeight || "normal";
674
+ const fontFamily = props.fontFamily || "system-ui, sans-serif";
675
+ const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
469
676
 
677
+ if (!edit) {
678
+ if (text) {
679
+ renderText(
680
+ ctx,
681
+ text,
682
+ layout.x + layout.contentX,
683
+ layout.y + layout.contentY,
684
+ layout.contentWidth,
685
+ layout.contentHeight,
686
+ {
687
+ color: textColor,
688
+ fontSize,
689
+ fontWeight,
690
+ fontFamily,
691
+ textAlign: "left",
692
+ verticalAlign: isSingleLine ? "middle" : "top",
693
+ lineHeight,
694
+ }
695
+ );
696
+ }
697
+ return;
698
+ }
699
+
700
+ // ---- Editing visuals: clip → selection highlight → text → composition
701
+ // ---- underline → caret. Geometry comes from the edit controller so
702
+ // ---- pointer mapping and painting share identical math (incl. scrollX).
703
+ ctx.save();
704
+ drawRoundedRect(ctx, x, y, width, height, radius);
705
+ ctx.clip();
706
+
707
+ const g = activeTextEditor!.geometry(
708
+ layout.x + layout.contentX - edit.scrollX,
709
+ layout.y + layout.contentY,
710
+ );
711
+
712
+ // Selection highlight behind the text
713
+ if (edit.selStart !== edit.selEnd) {
714
+ const from = Math.min(edit.selStart, edit.selEnd);
715
+ const to = Math.max(edit.selStart, edit.selEnd);
716
+ ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
717
+ for (const r of rangeToRects(ctx, g, from, to)) {
718
+ ctx.fillRect(r.x, r.y, r.width, r.height);
719
+ }
720
+ }
721
+
722
+ if (text) {
470
723
  renderText(
471
724
  ctx,
472
- text,
473
- layout.x + layout.contentX,
725
+ value || placeholder,
726
+ g.contentX,
474
727
  layout.y + layout.contentY,
475
- layout.contentWidth,
728
+ // Single-line inputs never wrap — the clip + scrollX pan handle
729
+ // overflow. Textareas wrap at content width like static paint.
730
+ isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth,
476
731
  layout.contentHeight,
477
732
  {
478
733
  color: textColor,
@@ -480,11 +735,34 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
480
735
  fontWeight,
481
736
  fontFamily,
482
737
  textAlign: "left",
483
- verticalAlign: "middle",
738
+ verticalAlign: isSingleLine ? "middle" : "top",
484
739
  lineHeight,
485
740
  }
486
741
  );
487
742
  }
743
+
744
+ // IME composition underline (dashed, under the composing range)
745
+ if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
746
+ ctx.strokeStyle = props.color || "#000000";
747
+ ctx.lineWidth = 1;
748
+ ctx.setLineDash([2, 2]);
749
+ for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
750
+ ctx.beginPath();
751
+ ctx.moveTo(r.x, r.y + r.height - 2);
752
+ ctx.lineTo(r.x + r.width, r.y + r.height - 2);
753
+ ctx.stroke();
754
+ }
755
+ ctx.setLineDash([]);
756
+ }
757
+
758
+ // Caret (hidden while a range is selected, blinks via the edit timer)
759
+ if (edit.caretVisible && edit.selStart === edit.selEnd) {
760
+ const caret = offsetToCaretRect(ctx, g, edit.selEnd);
761
+ ctx.fillStyle = props.color || "#000000";
762
+ ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
763
+ }
764
+
765
+ ctx.restore();
488
766
  }
489
767
 
490
768
  /**
@@ -648,8 +926,10 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
648
926
  imagePending.delete(src);
649
927
  imageFailCache.delete(src);
650
928
  evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
651
- // Request a repaint so the loaded image is drawn
652
- canvas.dispatchEvent(new CustomEvent("hypen:redraw"));
929
+ // Request a repaint so the loaded image is drawn. `layout: true`
930
+ // because the decoded intrinsic size can change the image's box
931
+ // (see getImageNaturalAspect in layout.ts).
932
+ canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
653
933
  };
654
934
  img.onerror = () => {
655
935
  imageInFlight.delete(src);
@@ -793,6 +1073,15 @@ function resolveBackgroundPaint(props: Record<string, any>): string | undefined
793
1073
  return undefined;
794
1074
  }
795
1075
 
1076
+ /**
1077
+ * Parsed gradients keyed by (source string, box geometry). CanvasGradient
1078
+ * objects are not bound to a specific context, so identical fills across
1079
+ * frames — the common case, since layout geometry is stable — skip the
1080
+ * regex parse and createLinearGradient/createRadialGradient entirely.
1081
+ */
1082
+ const gradientCache = new Map<string, CanvasGradient | string>();
1083
+ const MAX_GRADIENT_CACHE_SIZE = 256;
1084
+
796
1085
  function resolveCanvasPaint(
797
1086
  ctx: CanvasRenderingContext2D,
798
1087
  paint: string,
@@ -801,7 +1090,15 @@ function resolveCanvasPaint(
801
1090
  width: number,
802
1091
  height: number,
803
1092
  ): CanvasGradient | string {
804
- return paint.includes("gradient(") ? parseGradient(ctx, paint, x, y, width, height) : paint;
1093
+ if (!paint.includes("gradient(")) return paint;
1094
+ const key = `${paint}|${x}|${y}|${width}|${height}`;
1095
+ let resolved = gradientCache.get(key);
1096
+ if (resolved === undefined) {
1097
+ resolved = parseGradient(ctx, paint, x, y, width, height);
1098
+ gradientCache.set(key, resolved);
1099
+ evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
1100
+ }
1101
+ return resolved;
805
1102
  }
806
1103
 
807
1104
  /**
@@ -1268,7 +1565,7 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1268
1565
 
1269
1566
  // Support gradient fills
1270
1567
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1271
- ctx.fillStyle = parseGradient(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1568
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
1272
1569
  } else {
1273
1570
  ctx.fillStyle = fillColor;
1274
1571
  }
@@ -1323,7 +1620,7 @@ function paintProgress(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1323
1620
  // Support gradient fills
1324
1621
  const fillColor = props.fillColor || props.color || "#007bff";
1325
1622
  if (typeof fillColor === "string" && fillColor.includes("gradient")) {
1326
- ctx.fillStyle = parseGradient(ctx, fillColor, x, y, fillWidth, height);
1623
+ ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
1327
1624
  } else {
1328
1625
  ctx.fillStyle = fillColor;
1329
1626
  }