@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
@@ -21,10 +21,13 @@ import type {
21
21
  LayoutFunction,
22
22
  } from "./types.js";
23
23
  import { computeLayout, initTaffyLayout } from "./layout.js";
24
- import { paintNode, registerPainter } from "./paint.js";
24
+ import { clearTextCache } from "./text.js";
25
+ import { paintNode, registerPainter, clearCharAdvanceCache } from "./paint.js";
25
26
  import { CanvasEventManager } from "./events.js";
26
- import { InputOverlay } from "./input.js";
27
27
  import { AccessibilityLayer } from "./accessibility.js";
28
+ import { FocusManager } from "./focus.js";
29
+ import { TextEditController, isEditableNode } from "./editing.js";
30
+ import { setTextEditor } from "./paint.js";
28
31
  import { findNodeById } from "./utils.js";
29
32
  import { ScrollManager } from "./scroll.js";
30
33
  import { SelectionManager } from "./selection.js";
@@ -42,7 +45,6 @@ const DEFAULT_OPTIONS: CanvasRendererOptions = {
42
45
  backgroundColor: "#ffffff",
43
46
  enableAccessibility: true,
44
47
  enableHitTesting: true,
45
- enableInputOverlay: true,
46
48
  enableDirtyRects: false,
47
49
  enableLayerCaching: false,
48
50
  maxLayerCacheSize: 10,
@@ -66,17 +68,34 @@ export class CanvasRenderer implements Renderer {
66
68
  private eventManager: CanvasEventManager;
67
69
  private scrollManager: ScrollManager;
68
70
  private selectionManager: SelectionManager;
69
- private inputOverlay: InputOverlay;
70
71
  private accessibilityLayer: AccessibilityLayer;
72
+ private focusManager: FocusManager;
73
+ private textEditor: TextEditController;
71
74
 
72
75
  private dirtyTracker: DirtyRectTracker;
73
76
 
74
77
  private rafId: number | null = null;
75
78
  private needsRedraw = false;
76
79
 
80
+ // Whether the next frame must re-run layout. Patches, resize, font loads,
81
+ // and image decodes set it; pure paint frames (scroll, hover, caret blink,
82
+ // scrollbar fade) leave it clear so the frame skips the Taffy solve.
83
+ private layoutDirty = true;
84
+
77
85
  private frameCount = 0;
78
86
  private lastFrameTime = 0;
79
87
 
88
+ private boundFontsLoaded = () => {
89
+ // Metrics measured against the fallback font are stale now — both the
90
+ // wrapped-text metrics and the per-glyph advances used by the manual
91
+ // letter-spacing path (keyed by CSS font string, which does not change
92
+ // when the real font replaces the fallback).
93
+ clearTextCache();
94
+ clearCharAdvanceCache();
95
+ this.layoutDirty = true;
96
+ this.scheduleRedraw();
97
+ };
98
+
80
99
  constructor(canvas: HTMLCanvasElement, engine: IEngine, options?: Partial<CanvasRendererOptions>) {
81
100
  this.canvas = canvas;
82
101
  this.engine = engine;
@@ -102,42 +121,84 @@ export class CanvasRenderer implements Renderer {
102
121
  this.scrollManager = new ScrollManager(canvas, () => this.scheduleRedraw());
103
122
  this.selectionManager = new SelectionManager(canvas, () => this.scheduleRedraw());
104
123
  setSelectionManager(this.selectionManager);
105
- this.inputOverlay = new InputOverlay(
106
- (canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null)
107
- );
124
+ // The mirror is a transparent positioned overlay above the canvas: its
125
+ // elements are exposed to AT with REAL geometry (screen-reader browse
126
+ // modes are geometry-driven) and join the native tab order, while the
127
+ // canvas paints all pixels and keeps all pointer events.
108
128
  this.accessibilityLayer = new AccessibilityLayer(
109
- (canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null),
129
+ canvas as unknown as HTMLElement,
110
130
  this.options.enableAccessibility || false
111
131
  );
112
132
 
113
- // Bridge focus changes from the hit-tester to the HTML input overlay.
114
- // Without this, `Input`/`Textarea` painted on the canvas but clicking
115
- // one did nothing the overlay never got mounted.
116
- this.eventManager.setFocusChangeHandler((next) => {
117
- const t = next ? next.type.toLowerCase() : null;
118
- if (next && (t === "input" || t === "textarea")) {
119
- const rect = this.canvas.getBoundingClientRect();
120
- this.inputOverlay.showInput(
121
- next,
122
- rect,
123
- (value) => {
124
- // Local mirror so the next paint sees the typed text even
125
- // before the engine echoes it back via SetProp.
126
- next.props.value = value;
127
- this.scheduleRedraw();
128
- },
129
- this.engine,
130
- );
131
- } else {
132
- this.inputOverlay.hideInput();
133
+ // Text editing happens IN the focused mirror element (the browser owns
134
+ // value/caret/selection/IME there); the controller reads that state and
135
+ // the canvas paints text, selection, and caret natively.
136
+ this.textEditor = new TextEditController(canvas, engine, {
137
+ scheduleRedraw: () => this.scheduleRedraw(),
138
+ markDirty: (node) => {
139
+ if (this.options.enableDirtyRects) {
140
+ this.dirtyTracker.markNodeDirty(node);
141
+ }
142
+ },
143
+ // Proxy blurred to somewhere outside the canvas: node.focused follows.
144
+ onEditBlur: () => this.focusManager.clearFocus(),
145
+ });
146
+ setTextEditor(this.textEditor);
147
+
148
+ // DOM focus on mirror elements (canvas fallback content) is the single
149
+ // source of truth for focus: Tab/Shift+Tab traverse the mirror natively,
150
+ // and the pointer path (hit-test in CanvasEventManager) funnels into the
151
+ // same place by focusing the node's mirror element.
152
+ this.focusManager = new FocusManager(this.accessibilityLayer, engine, {
153
+ getNode: (id) => this.nodes.get(id),
154
+ isAuxFocusTarget: (el) => this.textEditor.isProxyElement(el),
155
+ onFocusChange: (next) => {
156
+ if (next && isEditableNode(next)) {
157
+ const el = this.accessibilityLayer.getElement(next.id);
158
+ if (el) {
159
+ this.textEditor.beginEditing(
160
+ next,
161
+ el as HTMLInputElement | HTMLTextAreaElement,
162
+ );
163
+ }
164
+ } else {
165
+ this.textEditor.endEditing();
166
+ }
167
+ // Repaint so focus styling (input border, button ring) updates.
168
+ this.scheduleRedraw();
169
+ },
170
+ });
171
+ this.eventManager.setFocusManager(this.focusManager);
172
+ this.eventManager.setEditablePointerHandler((node, point) => {
173
+ this.textEditor.placeCaretFromPoint(node, point);
174
+ });
175
+
176
+ // Listen for redraw requests from event manager (paint-only) and image
177
+ // loads (which carry `detail.layout` — a decoded intrinsic size can
178
+ // change the layout, see `getImageNaturalAspect`).
179
+ this.canvas.addEventListener("hypen:redraw", (e: Event) => {
180
+ if ((e as CustomEvent).detail?.layout) {
181
+ this.layoutDirty = true;
133
182
  }
183
+ this.scheduleRedraw();
134
184
  });
135
185
 
136
- // Listen for redraw requests from event manager
137
- this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
186
+ // A late-loading web font changes every measurement made against the
187
+ // fallback font — re-run layout once the real metrics are available.
188
+ if (typeof document !== "undefined") {
189
+ (document as any).fonts?.addEventListener?.("loadingdone", this.boundFontsLoaded);
190
+ }
138
191
 
139
- // Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
140
- initTaffyLayout();
192
+ // Eagerly initialise Taffy WASM for layout (non-blocking — fallback used
193
+ // until ready). Repaint once it arrives: the fallback's first frame is
194
+ // approximate, and without this redraw its layout would stick until the
195
+ // next state change.
196
+ initTaffyLayout().then((ready) => {
197
+ if (ready) {
198
+ this.layoutDirty = true;
199
+ this.scheduleRedraw();
200
+ }
201
+ });
141
202
 
142
203
  // Don't schedule initial render - wait for patches
143
204
  }
@@ -173,6 +234,7 @@ export class CanvasRenderer implements Renderer {
173
234
  this.canvas.width = width;
174
235
  this.canvas.height = height;
175
236
  this.setupHiDPI();
237
+ this.layoutDirty = true;
176
238
  this.scheduleRedraw();
177
239
  }
178
240
 
@@ -191,10 +253,12 @@ export class CanvasRenderer implements Renderer {
191
253
  this.dirtyTracker.markFullDirty();
192
254
  }
193
255
 
194
- // Update accessibility layer
195
- if (this.rootNode) {
196
- this.accessibilityLayer.syncTree(this.rootNode);
197
- }
256
+ // The accessibility mirror is synced incrementally by the per-patch
257
+ // handlers above — no per-batch rebuild, so mirror element identity
258
+ // (and with it AT focus/virtual-cursor position) survives updates.
259
+
260
+ // Any patch can affect layout (props, tree shape, text).
261
+ this.layoutDirty = true;
198
262
 
199
263
  // Schedule redraw
200
264
  this.scheduleRedraw();
@@ -206,7 +270,7 @@ export class CanvasRenderer implements Renderer {
206
270
  private applyPatch(patch: Patch): void {
207
271
  switch (patch.type) {
208
272
  case "create":
209
- this.onCreate(patch.id!, patch.elementType!, patch.props || {});
273
+ this.onCreate(patch.id!, patch.elementType!, patch.props || {}, patch.semantics);
210
274
  break;
211
275
 
212
276
  case "setProp":
@@ -240,13 +304,38 @@ export class CanvasRenderer implements Renderer {
240
304
  case "attach":
241
305
  this.onInsert(patch.parentId!, patch.id!, patch.beforeId);
242
306
  break;
307
+
308
+ case "setSemantics":
309
+ this.onSetSemantics(patch.id!, patch.semantics);
310
+ break;
243
311
  }
244
312
  }
245
313
 
314
+ /**
315
+ * Reactive accessibility update: swap in the node's complete re-resolved
316
+ * semantics block and re-apply it to the shadow element. This is the only
317
+ * channel that keeps the canvas shadow tree's accessible name/state live —
318
+ * painted text is invisible to AT, so prop deltas alone can't do it.
319
+ */
320
+ private onSetSemantics(
321
+ id: string,
322
+ semantics?: import("@hypen-space/core/types").Semantics,
323
+ ): void {
324
+ const node = this.nodes.get(id);
325
+ if (!node) return;
326
+ node.semantics = semantics;
327
+ this.accessibilityLayer.updateNode(node);
328
+ }
329
+
246
330
  /**
247
331
  * Create new virtual node
248
332
  */
249
- private onCreate(id: string, elementType: string, props: Record<string, any>): void {
333
+ private onCreate(
334
+ id: string,
335
+ elementType: string,
336
+ props: Record<string, any>,
337
+ semantics?: import("@hypen-space/core/types").Semantics,
338
+ ): void {
250
339
  // Engine may send a Map (from WASM) or a plain object. Copy either way so
251
340
  // we own the prop bag and can mutate it during applicator normalisation.
252
341
  const rawProps: Record<string, any> =
@@ -263,6 +352,7 @@ export class CanvasRenderer implements Renderer {
263
352
  id,
264
353
  type: elementType,
265
354
  props: rawProps,
355
+ semantics,
266
356
  children: [],
267
357
  parent: null,
268
358
  visible: true,
@@ -279,6 +369,9 @@ export class CanvasRenderer implements Renderer {
279
369
  };
280
370
 
281
371
  this.nodes.set(id, node);
372
+
373
+ // Mirror the create (element stays detached until its insert patch).
374
+ this.accessibilityLayer.createNode(node);
282
375
  }
283
376
 
284
377
  /**
@@ -332,6 +425,12 @@ export class CanvasRenderer implements Renderer {
332
425
 
333
426
  // Update accessibility
334
427
  this.accessibilityLayer.updateNode(node);
428
+
429
+ // Engine value echo for an actively edited input: same value → no-op
430
+ // (the caret must not move); a rewritten value re-seeds the element.
431
+ if (name === "value") {
432
+ this.textEditor.onEngineValueEcho(node);
433
+ }
335
434
  }
336
435
 
337
436
  /**
@@ -405,6 +504,10 @@ export class CanvasRenderer implements Renderer {
405
504
  const child = this.nodes.get(id);
406
505
  if (!child) return;
407
506
 
507
+ // Mirror the insert/attach/move — insertNode resolves root addressing
508
+ // and unknown-beforeId fallback with the same rules as the code below.
509
+ this.accessibilityLayer.insertNode(parentId, id, beforeId);
510
+
408
511
  // Check if this is setting the root node (parent_id === id === "root" or similar)
409
512
  if (parentId === "root" && id === "root") {
410
513
  this.rootNode = child;
@@ -488,6 +591,15 @@ export class CanvasRenderer implements Renderer {
488
591
  const node = this.nodes.get(id);
489
592
  if (!node) return;
490
593
 
594
+ // An edit session inside the detached subtree ends (flushing any
595
+ // pending composition value) before the subtree leaves the tree, and
596
+ // focus state follows.
597
+ this.textEditor.endIfWithin(node);
598
+ this.focusManager.clearIfWithin(node);
599
+
600
+ // Mirror keeps the element (and subtree ids) alive for re-attach.
601
+ this.accessibilityLayer.detachNode(id);
602
+
491
603
  // Mark dirty so the next paint doesn't leave stale pixels behind.
492
604
  if (this.options.enableDirtyRects) {
493
605
  this.dirtyTracker.markNodeDirty(node);
@@ -519,6 +631,13 @@ export class CanvasRenderer implements Renderer {
519
631
  const node = this.nodes.get(id);
520
632
  if (!node) return;
521
633
 
634
+ // End any edit session inside the removed subtree; focus state follows.
635
+ this.textEditor.endIfWithin(node);
636
+ this.focusManager.clearIfWithin(node);
637
+
638
+ // Drop the mirror element and its subtree's id mappings.
639
+ this.accessibilityLayer.removeNode(node);
640
+
522
641
  // Mark dirty before removal
523
642
  if (this.options.enableDirtyRects) {
524
643
  this.dirtyTracker.markNodeDirty(node);
@@ -575,6 +694,16 @@ export class CanvasRenderer implements Renderer {
575
694
  this.renderFull(dpr);
576
695
  }
577
696
 
697
+ // Layout may have moved the edited input — keep the IME proxy (and with
698
+ // it the IME candidate window) pinned to the painted caret.
699
+ if (this.textEditor.isActive()) {
700
+ this.textEditor.syncProxyPosition();
701
+ }
702
+
703
+ // Keep the semantics overlay's element boxes on the painted bounds so
704
+ // screen-reader browse modes (geometry-driven) track layout changes.
705
+ this.accessibilityLayer.syncPositions(this.rootNode);
706
+
578
707
  // Performance logging
579
708
  if (this.options.logPerformance) {
580
709
  const elapsed = performance.now() - startTime;
@@ -601,6 +730,37 @@ export class CanvasRenderer implements Renderer {
601
730
  }
602
731
  }
603
732
 
733
+ /**
734
+ * Resolve variants and re-run layout when (and only when) something that
735
+ * affects layout changed since the last frame. Variants are resolved every
736
+ * frame — hover/focus/pressed winners must reach paint — but the Taffy
737
+ * solve, computed-prop refresh, and scroll-bounds pass only run when a
738
+ * patch/resize/font/image marked the layout dirty or a variant winner
739
+ * actually changed (variants can rewrite spacing/size props).
740
+ */
741
+ private runLayoutIfNeeded(dpr: number): void {
742
+ if (!this.rootNode) return;
743
+
744
+ // Resolve responsive + state variants against the current content width
745
+ // BEFORE layout so spacing/size winners feed the layout engine and
746
+ // colour/opacity winners feed paint.
747
+ const contentWidth = this.canvas.width / dpr;
748
+ const variantsChanged = applyVariants(this.rootNode, contentWidth);
749
+ if (!variantsChanged && !this.layoutDirty) return;
750
+
751
+ this.refreshComputedProps(this.rootNode);
752
+
753
+ computeLayout(
754
+ this.ctx,
755
+ this.rootNode,
756
+ contentWidth,
757
+ this.canvas.height / dpr
758
+ );
759
+
760
+ ScrollManager.updateScrollBounds(this.rootNode);
761
+ this.layoutDirty = false;
762
+ }
763
+
604
764
  /**
605
765
  * Full canvas repaint (default behavior when dirty rects disabled)
606
766
  */
@@ -616,21 +776,7 @@ export class CanvasRenderer implements Renderer {
616
776
 
617
777
  // Layout and paint
618
778
  if (this.rootNode) {
619
- // Resolve responsive + state variants against the current content width
620
- // BEFORE layout so spacing/size winners feed the layout engine and
621
- // colour/opacity winners feed paint.
622
- const contentWidth = this.canvas.width / dpr;
623
- applyVariants(this.rootNode, contentWidth);
624
- this.refreshComputedProps(this.rootNode);
625
-
626
- computeLayout(
627
- this.ctx,
628
- this.rootNode,
629
- contentWidth,
630
- this.canvas.height / dpr
631
- );
632
-
633
- ScrollManager.updateScrollBounds(this.rootNode);
779
+ this.runLayoutIfNeeded(dpr);
634
780
 
635
781
  paintNode(this.ctx, this.rootNode);
636
782
 
@@ -644,20 +790,7 @@ export class CanvasRenderer implements Renderer {
644
790
  * Optimized render that only repaints dirty regions
645
791
  */
646
792
  private renderWithDirtyRects(dpr: number): void {
647
- // Always run layout so nodes have up-to-date bounds
648
- if (this.rootNode) {
649
- const contentWidth = this.canvas.width / dpr;
650
- applyVariants(this.rootNode, contentWidth);
651
- this.refreshComputedProps(this.rootNode);
652
-
653
- computeLayout(
654
- this.ctx,
655
- this.rootNode,
656
- contentWidth,
657
- this.canvas.height / dpr
658
- );
659
- ScrollManager.updateScrollBounds(this.rootNode);
660
- }
793
+ this.runLayoutIfNeeded(dpr);
661
794
 
662
795
  const dirtyRegion = this.dirtyTracker.getDirtyRegion();
663
796
  this.dirtyTracker.clear();
@@ -683,9 +816,11 @@ export class CanvasRenderer implements Renderer {
683
816
  this.ctx.fillRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
684
817
  }
685
818
 
686
- // Repaint the full tree — clip path ensures only dirty pixels are touched
819
+ // Repaint the tree — the clip path bounds rasterized pixels and the
820
+ // dirty region is threaded through paintNode so subtrees that cannot
821
+ // reach it are pruned from the traversal entirely.
687
822
  if (this.rootNode) {
688
- paintNode(this.ctx, this.rootNode);
823
+ paintNode(this.ctx, this.rootNode, dirtyRegion);
689
824
 
690
825
  if (this.options.showLayoutBounds) {
691
826
  this.drawLayoutBounds(this.rootNode);
@@ -734,11 +869,13 @@ export class CanvasRenderer implements Renderer {
734
869
  * Clear renderer
735
870
  */
736
871
  clear(): void {
872
+ this.textEditor.endEditing();
737
873
  this.rootNode = null;
738
874
  this.nodes.clear();
739
875
  this.eventManager.setRootNode(null);
740
876
  this.scrollManager.setRootNode(null);
741
877
  this.selectionManager.setRootNode(null);
878
+ this.accessibilityLayer.rebuild(null);
742
879
  this.dirtyTracker.clear();
743
880
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
744
881
  }
@@ -755,7 +892,12 @@ export class CanvasRenderer implements Renderer {
755
892
  */
756
893
  setOptions(options: Partial<CanvasRendererOptions>): void {
757
894
  this.options = { ...this.options, ...options };
758
- this.accessibilityLayer.setEnabled(this.options.enableAccessibility || false);
895
+ // Re-enabling rebuilds the mirror from the current tree — incremental
896
+ // sync has no history to replay for the disabled period.
897
+ this.accessibilityLayer.setEnabled(
898
+ this.options.enableAccessibility || false,
899
+ this.rootNode,
900
+ );
759
901
  }
760
902
 
761
903
  /**
@@ -765,10 +907,16 @@ export class CanvasRenderer implements Renderer {
765
907
  if (this.rafId !== null) {
766
908
  cancelAnimationFrame(this.rafId);
767
909
  }
910
+ if (typeof document !== "undefined") {
911
+ (document as any).fonts?.removeEventListener?.("loadingdone", this.boundFontsLoaded);
912
+ }
768
913
  this.eventManager.destroy();
769
914
  this.scrollManager.destroy();
770
915
  this.selectionManager.destroy();
771
916
  setSelectionManager(null);
917
+ this.textEditor.destroy();
918
+ setTextEditor(null);
919
+ this.focusManager.destroy();
772
920
  this.accessibilityLayer.destroy();
773
921
  }
774
922
  }