@hypen-space/web 0.4.46 → 0.4.82

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 (69) hide show
  1. package/README.md +4 -4
  2. package/dist/canvas/dirty.d.ts +40 -0
  3. package/dist/canvas/dirty.js +100 -0
  4. package/dist/canvas/dirty.js.map +1 -0
  5. package/dist/canvas/events.d.ts +5 -0
  6. package/dist/canvas/events.js +30 -3
  7. package/dist/canvas/events.js.map +1 -1
  8. package/dist/canvas/index.d.ts +5 -1
  9. package/dist/canvas/index.js +4 -0
  10. package/dist/canvas/index.js.map +1 -1
  11. package/dist/canvas/layout.d.ts +12 -2
  12. package/dist/canvas/layout.js +593 -63
  13. package/dist/canvas/layout.js.map +1 -1
  14. package/dist/canvas/paint.d.ts +2 -0
  15. package/dist/canvas/paint.js +88 -11
  16. package/dist/canvas/paint.js.map +1 -1
  17. package/dist/canvas/renderer.d.ts +16 -0
  18. package/dist/canvas/renderer.js +154 -15
  19. package/dist/canvas/renderer.js.map +1 -1
  20. package/dist/canvas/scroll.d.ts +82 -0
  21. package/dist/canvas/scroll.js +639 -0
  22. package/dist/canvas/scroll.js.map +1 -0
  23. package/dist/canvas/selection.d.ts +63 -0
  24. package/dist/canvas/selection.js +466 -0
  25. package/dist/canvas/selection.js.map +1 -0
  26. package/dist/canvas/text.d.ts +6 -3
  27. package/dist/canvas/text.js +82 -43
  28. package/dist/canvas/text.js.map +1 -1
  29. package/dist/canvas/types.d.ts +18 -0
  30. package/dist/canvas/utils.d.ts +8 -2
  31. package/dist/canvas/utils.js +42 -16
  32. package/dist/canvas/utils.js.map +1 -1
  33. package/dist/canvas/virtualize.d.ts +25 -0
  34. package/dist/canvas/virtualize.js +65 -0
  35. package/dist/canvas/virtualize.js.map +1 -0
  36. package/dist/dom/applicators/index.js +0 -1
  37. package/dist/dom/applicators/index.js.map +1 -1
  38. package/dist/dom/applicators/margin.js +58 -17
  39. package/dist/dom/applicators/margin.js.map +1 -1
  40. package/dist/dom/applicators/padding.js +42 -17
  41. package/dist/dom/applicators/padding.js.map +1 -1
  42. package/dist/dom/components/icon.d.ts +9 -0
  43. package/dist/dom/components/icon.js +67 -0
  44. package/dist/dom/components/icon.js.map +1 -0
  45. package/dist/dom/components/index.js +2 -0
  46. package/dist/dom/components/index.js.map +1 -1
  47. package/dist/dom/renderer.d.ts +19 -5
  48. package/dist/dom/renderer.js +147 -17
  49. package/dist/dom/renderer.js.map +1 -1
  50. package/package.json +4 -2
  51. package/src/canvas/QUICKSTART.md +5 -5
  52. package/src/canvas/dirty.ts +116 -0
  53. package/src/canvas/events.ts +34 -3
  54. package/src/canvas/index.ts +5 -0
  55. package/src/canvas/layout.ts +653 -100
  56. package/src/canvas/paint.ts +109 -11
  57. package/src/canvas/renderer.ts +189 -16
  58. package/src/canvas/scroll.ts +729 -0
  59. package/src/canvas/selection.ts +560 -0
  60. package/src/canvas/text.ts +97 -54
  61. package/src/canvas/types.ts +26 -0
  62. package/src/canvas/utils.ts +47 -17
  63. package/src/canvas/virtualize.ts +81 -0
  64. package/src/dom/applicators/index.ts +1 -1
  65. package/src/dom/applicators/margin.ts +57 -11
  66. package/src/dom/applicators/padding.ts +45 -11
  67. package/src/dom/components/icon.ts +84 -0
  68. package/src/dom/components/index.ts +2 -0
  69. package/src/dom/renderer.ts +155 -17
@@ -6,6 +6,18 @@
6
6
 
7
7
  import type { VirtualNode, PainterFunction } from "./types.js";
8
8
  import { renderText } from "./text.js";
9
+ import { ScrollManager, isScrollable } from "./scroll.js";
10
+ import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
11
+ import type { SelectionManager } from "./selection.js";
12
+
13
+ /**
14
+ * Module-level reference to the active SelectionManager so paintText
15
+ * can render selection highlights. Set once from the renderer.
16
+ */
17
+ let activeSelectionManager: SelectionManager | null = null;
18
+ export function setSelectionManager(mgr: SelectionManager | null): void {
19
+ activeSelectionManager = mgr;
20
+ }
9
21
 
10
22
  /**
11
23
  * Custom painters registry
@@ -116,11 +128,44 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
116
128
 
117
129
  ctx.restore();
118
130
 
119
- // Paint children
120
- for (const child of node.children) {
131
+ // Apply scroll translation for scrollable containers
132
+ const ss = node.scrollState;
133
+ if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
134
+ ctx.save();
135
+ ctx.translate(-ss.scrollX, -ss.scrollY);
136
+ (node as any)._scrollRestore = true;
137
+ }
138
+
139
+ // Paint children -- use windowed rendering for large scrollable lists
140
+ const shouldVirtualize =
141
+ isScrollable(node) &&
142
+ node.children.length > VIRTUALIZE_THRESHOLD &&
143
+ node.layout != null;
144
+
145
+ const childrenToPaint = shouldVirtualize
146
+ ? getVisibleChildren(node, {
147
+ x: node.layout!.x,
148
+ y: node.layout!.y,
149
+ width: node.layout!.width,
150
+ height: node.layout!.height,
151
+ })
152
+ : node.children;
153
+
154
+ for (const child of childrenToPaint) {
121
155
  paintNode(ctx, child);
122
156
  }
123
157
 
158
+ // Undo scroll translation before drawing scrollbars
159
+ if ((node as any)._scrollRestore) {
160
+ ctx.restore();
161
+ delete (node as any)._scrollRestore;
162
+ }
163
+
164
+ // Paint scrollbar indicators (on top of children, inside clip)
165
+ if (ss && ss.scrollbarOpacity > 0) {
166
+ ScrollManager.paintScrollbars(ctx, node);
167
+ }
168
+
124
169
  // Restore context if overflow clipping was applied
125
170
  if ((node as any)._needsRestore) {
126
171
  ctx.restore();
@@ -206,6 +251,11 @@ function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void
206
251
  * Paint text node
207
252
  */
208
253
  function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
254
+ // Draw selection highlight behind text
255
+ if (activeSelectionManager) {
256
+ activeSelectionManager.paintSelection(ctx, node);
257
+ }
258
+
209
259
  const layout = node.layout!;
210
260
  const props = node.props;
211
261
 
@@ -1217,7 +1267,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1217
1267
  }
1218
1268
 
1219
1269
  /**
1220
- * Paint icon node (simple placeholder - real icons would need SVG path support)
1270
+ * Paint icon node using SVG path data from the engine's icon registry.
1271
+ * Falls back to simple shapes if no path data is provided.
1221
1272
  */
1222
1273
  function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1223
1274
  const layout = node.layout!;
@@ -1226,25 +1277,71 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1226
1277
  const size = Math.min(layout.width, layout.height);
1227
1278
  const x = layout.x;
1228
1279
  const y = layout.y;
1229
- const color = props.color || "#000000";
1230
- const iconName = props.icon || props.name || "circle";
1280
+ const color = props.color || props["color.0"] || "#000000";
1281
+
1282
+ // Server-resolved SVG path data (from engine's ResourceRegistry)
1283
+ const iconPaths: Array<{
1284
+ d: string;
1285
+ fill?: string;
1286
+ stroke?: string;
1287
+ strokeWidth?: number;
1288
+ }> = props.__iconPaths;
1289
+ const viewBox: string = props.__iconViewBox || "0 0 24 24";
1290
+
1291
+ if (iconPaths && Array.isArray(iconPaths) && iconPaths.length > 0) {
1292
+ // Parse viewBox to compute scale
1293
+ const vbParts = viewBox.split(" ").map(Number);
1294
+ const vbWidth = vbParts[2] || 24;
1295
+ const vbHeight = vbParts[3] || 24;
1296
+ const scale = size / Math.max(vbWidth, vbHeight);
1297
+
1298
+ ctx.save();
1299
+ ctx.translate(x, y);
1300
+ ctx.scale(scale, scale);
1301
+
1302
+ for (const pathData of iconPaths) {
1303
+ const path2d = new Path2D(pathData.d);
1304
+ const fill = pathData.fill || "none";
1305
+ const stroke =
1306
+ (pathData.stroke || "currentColor") === "currentColor"
1307
+ ? color
1308
+ : pathData.stroke || color;
1309
+
1310
+ if (fill && fill !== "none") {
1311
+ ctx.fillStyle = fill === "currentColor" ? color : fill;
1312
+ ctx.fill(path2d);
1313
+ }
1314
+ if (stroke && stroke !== "none") {
1315
+ ctx.strokeStyle = stroke;
1316
+ ctx.lineWidth = (pathData.strokeWidth || 2) ;
1317
+ ctx.lineCap = "round";
1318
+ ctx.lineJoin = "round";
1319
+ ctx.stroke(path2d);
1320
+ }
1321
+ }
1231
1322
 
1323
+ ctx.restore();
1324
+ return;
1325
+ }
1326
+
1327
+ // Fallback: simple shapes for when no icon registry is available
1328
+ const iconName = props.icon || props.name || props["0"] || "circle";
1232
1329
  ctx.fillStyle = color;
1233
1330
  ctx.strokeStyle = color;
1234
1331
  ctx.lineWidth = 2;
1235
1332
 
1236
- // Simple icon shapes
1237
- switch (iconName.toLowerCase()) {
1333
+ switch (String(iconName).toLowerCase()) {
1238
1334
  case "circle":
1239
1335
  ctx.beginPath();
1240
1336
  ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
1241
1337
  ctx.fill();
1242
1338
  break;
1243
1339
 
1244
- case "square":
1340
+ case "square": {
1245
1341
  const padding = size * 0.2;
1246
1342
  ctx.fillRect(x + padding, y + padding, size - padding * 2, size - padding * 2);
1247
1343
  break;
1344
+ }
1248
1345
 
1249
1346
  case "star":
1250
1347
  drawStar(ctx, x + size / 2, y + size / 2, 5, size / 3, size / 6);
@@ -1252,7 +1349,7 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1252
1349
  break;
1253
1350
 
1254
1351
  case "check":
1255
- case "checkmark":
1352
+ case "checkmark": {
1256
1353
  const p = size * 0.2;
1257
1354
  ctx.beginPath();
1258
1355
  ctx.moveTo(x + p, y + size / 2);
@@ -1260,9 +1357,10 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1260
1357
  ctx.lineTo(x + size - p, y + p);
1261
1358
  ctx.stroke();
1262
1359
  break;
1360
+ }
1263
1361
 
1264
1362
  case "x":
1265
- case "close":
1363
+ case "close": {
1266
1364
  const pd = size * 0.2;
1267
1365
  ctx.beginPath();
1268
1366
  ctx.moveTo(x + pd, y + pd);
@@ -1271,9 +1369,9 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
1271
1369
  ctx.lineTo(x + pd, y + size - pd);
1272
1370
  ctx.stroke();
1273
1371
  break;
1372
+ }
1274
1373
 
1275
1374
  default:
1276
- // Default: filled circle
1277
1375
  ctx.beginPath();
1278
1376
  ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
1279
1377
  ctx.fill();
@@ -20,12 +20,16 @@ import type {
20
20
  PainterFunction,
21
21
  LayoutFunction,
22
22
  } from "./types.js";
23
- import { computeLayout } from "./layout.js";
23
+ import { computeLayout, initTaffyLayout } from "./layout.js";
24
24
  import { paintNode, registerPainter } from "./paint.js";
25
25
  import { CanvasEventManager } from "./events.js";
26
26
  import { InputOverlay } from "./input.js";
27
27
  import { AccessibilityLayer } from "./accessibility.js";
28
28
  import { findNodeById } from "./utils.js";
29
+ import { ScrollManager } from "./scroll.js";
30
+ import { SelectionManager } from "./selection.js";
31
+ import { setSelectionManager } from "./paint.js";
32
+ import { DirtyRectTracker } from "./dirty.js";
29
33
 
30
34
  const DEFAULT_OPTIONS: CanvasRendererOptions = {
31
35
  devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
@@ -54,12 +58,16 @@ export class CanvasRenderer implements Renderer {
54
58
  private nodes = new Map<string, VirtualNode>();
55
59
 
56
60
  private eventManager: CanvasEventManager;
61
+ private scrollManager: ScrollManager;
62
+ private selectionManager: SelectionManager;
57
63
  private inputOverlay: InputOverlay;
58
64
  private accessibilityLayer: AccessibilityLayer;
59
65
 
66
+ private dirtyTracker: DirtyRectTracker;
67
+
60
68
  private rafId: number | null = null;
61
69
  private needsRedraw = false;
62
-
70
+
63
71
  private frameCount = 0;
64
72
  private lastFrameTime = 0;
65
73
 
@@ -78,8 +86,16 @@ export class CanvasRenderer implements Renderer {
78
86
  // Setup HiDPI
79
87
  this.setupHiDPI();
80
88
 
89
+ // Initialize dirty rect tracker
90
+ const dpr = this.options.devicePixelRatio || 1;
91
+ const rect = canvas.getBoundingClientRect();
92
+ this.dirtyTracker = new DirtyRectTracker(rect.width, rect.height);
93
+
81
94
  // Initialize subsystems
82
95
  this.eventManager = new CanvasEventManager(canvas, engine);
96
+ this.scrollManager = new ScrollManager(canvas, () => this.scheduleRedraw());
97
+ this.selectionManager = new SelectionManager(canvas, () => this.scheduleRedraw());
98
+ setSelectionManager(this.selectionManager);
83
99
  this.inputOverlay = new InputOverlay(
84
100
  (canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null)
85
101
  );
@@ -91,6 +107,9 @@ export class CanvasRenderer implements Renderer {
91
107
  // Listen for redraw requests from event manager
92
108
  this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
93
109
 
110
+ // Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
111
+ initTaffyLayout();
112
+
94
113
  // Don't schedule initial render - wait for patches
95
114
  }
96
115
 
@@ -109,16 +128,40 @@ export class CanvasRenderer implements Renderer {
109
128
  // Update canvas display size
110
129
  this.canvas.style.width = `${rect.width}px`;
111
130
  this.canvas.style.height = `${rect.height}px`;
131
+
132
+ // Sync dirty tracker with new canvas size
133
+ if (this.dirtyTracker) {
134
+ this.dirtyTracker.setCanvasSize(rect.width, rect.height);
135
+ this.dirtyTracker.markFullDirty();
136
+ }
137
+ }
138
+
139
+ /**
140
+ * Resize the canvas and re-run HiDPI setup + redraw.
141
+ * Called by DOMRenderer when the Canvas component's width/height props change.
142
+ */
143
+ resize(width: number, height: number): void {
144
+ this.canvas.width = width;
145
+ this.canvas.height = height;
146
+ this.setupHiDPI();
147
+ this.scheduleRedraw();
112
148
  }
113
149
 
114
150
  /**
115
151
  * Apply patches from engine
116
152
  */
117
153
  applyPatches(patches: Patch[]): void {
154
+ const hadRoot = this.rootNode !== null;
155
+
118
156
  for (const patch of patches) {
119
157
  this.applyPatch(patch);
120
158
  }
121
159
 
160
+ // On first render (root just appeared), mark the entire canvas dirty
161
+ if (this.options.enableDirtyRects && !hadRoot && this.rootNode !== null) {
162
+ this.dirtyTracker.markFullDirty();
163
+ }
164
+
122
165
  // Update accessibility layer
123
166
  if (this.rootNode) {
124
167
  this.accessibilityLayer.syncTree(this.rootNode);
@@ -175,7 +218,7 @@ export class CanvasRenderer implements Renderer {
175
218
  parent: null,
176
219
  visible: true,
177
220
  opacity: parseFloat(props.opacity) || 1,
178
- clickable: elementType === "button" || !!props.onclick,
221
+ clickable: elementType === "button" || !!props.onclick || !!props.action,
179
222
  hoverable: true,
180
223
  focusable: elementType === "input" || elementType === "textarea" || elementType === "button",
181
224
  focused: false,
@@ -192,6 +235,11 @@ export class CanvasRenderer implements Renderer {
192
235
  const node = this.nodes.get(id);
193
236
  if (!node) return;
194
237
 
238
+ // Mark dirty before prop change (old bounds)
239
+ if (this.options.enableDirtyRects) {
240
+ this.dirtyTracker.markNodeDirty(node);
241
+ }
242
+
195
243
  node.props[name] = value;
196
244
 
197
245
  // Update computed properties
@@ -201,6 +249,14 @@ export class CanvasRenderer implements Renderer {
201
249
  if (name === "opacity") {
202
250
  node.opacity = parseFloat(value) || 1;
203
251
  }
252
+ if (name === "action" || name === "onclick") {
253
+ node.clickable = !!value;
254
+ }
255
+
256
+ // Mark dirty after prop change (new bounds will be captured after layout)
257
+ if (this.options.enableDirtyRects) {
258
+ this.dirtyTracker.markNodeDirty(node);
259
+ }
204
260
 
205
261
  // Update accessibility
206
262
  this.accessibilityLayer.updateNode(node);
@@ -213,6 +269,10 @@ export class CanvasRenderer implements Renderer {
213
269
  const node = this.nodes.get(id);
214
270
  if (!node) return;
215
271
 
272
+ if (this.options.enableDirtyRects) {
273
+ this.dirtyTracker.markNodeDirty(node);
274
+ }
275
+
216
276
  delete node.props[name];
217
277
 
218
278
  if (name === "visible") {
@@ -222,6 +282,10 @@ export class CanvasRenderer implements Renderer {
222
282
  node.opacity = 1;
223
283
  }
224
284
 
285
+ if (this.options.enableDirtyRects) {
286
+ this.dirtyTracker.markNodeDirty(node);
287
+ }
288
+
225
289
  this.accessibilityLayer.updateNode(node);
226
290
  }
227
291
 
@@ -232,8 +296,16 @@ export class CanvasRenderer implements Renderer {
232
296
  const node = this.nodes.get(id);
233
297
  if (!node) return;
234
298
 
299
+ if (this.options.enableDirtyRects) {
300
+ this.dirtyTracker.markNodeDirty(node);
301
+ }
302
+
235
303
  node.props[0] = text;
236
304
 
305
+ if (this.options.enableDirtyRects) {
306
+ this.dirtyTracker.markNodeDirty(node);
307
+ }
308
+
237
309
  // Update accessibility
238
310
  this.accessibilityLayer.updateNode(node);
239
311
  }
@@ -249,6 +321,8 @@ export class CanvasRenderer implements Renderer {
249
321
  if (parentId === "root" && id === "root") {
250
322
  this.rootNode = child;
251
323
  this.eventManager.setRootNode(child);
324
+ this.scrollManager.setRootNode(child);
325
+ this.selectionManager.setRootNode(child);
252
326
  return;
253
327
  }
254
328
 
@@ -259,6 +333,8 @@ export class CanvasRenderer implements Renderer {
259
333
  if (parentId === "root") {
260
334
  this.rootNode = child;
261
335
  this.eventManager.setRootNode(child);
336
+ this.scrollManager.setRootNode(child);
337
+ this.selectionManager.setRootNode(child);
262
338
  }
263
339
  return;
264
340
  }
@@ -276,6 +352,11 @@ export class CanvasRenderer implements Renderer {
276
352
  } else {
277
353
  parent.children.push(child);
278
354
  }
355
+
356
+ // Mark parent dirty since its children changed
357
+ if (this.options.enableDirtyRects) {
358
+ this.dirtyTracker.markNodeDirty(parent);
359
+ }
279
360
  }
280
361
 
281
362
  /**
@@ -287,12 +368,19 @@ export class CanvasRenderer implements Renderer {
287
368
  if (!node || !node.parent) return;
288
369
 
289
370
  const oldParent = node.parent;
371
+
372
+ // Mark old position dirty before move
373
+ if (this.options.enableDirtyRects) {
374
+ this.dirtyTracker.markNodeDirty(node);
375
+ this.dirtyTracker.markNodeDirty(oldParent);
376
+ }
377
+
290
378
  const oldIndex = oldParent.children.indexOf(node);
291
379
  if (oldIndex >= 0) {
292
380
  oldParent.children.splice(oldIndex, 1);
293
381
  }
294
382
 
295
- // Insert into new location
383
+ // Insert into new location (also marks new parent dirty)
296
384
  this.onInsert(parentId, id, beforeId);
297
385
  }
298
386
 
@@ -303,6 +391,11 @@ export class CanvasRenderer implements Renderer {
303
391
  const node = this.nodes.get(id);
304
392
  if (!node) return;
305
393
 
394
+ // Mark dirty before removal
395
+ if (this.options.enableDirtyRects) {
396
+ this.dirtyTracker.markNodeDirty(node);
397
+ }
398
+
306
399
  // Remove from parent
307
400
  if (node.parent) {
308
401
  const index = node.parent.children.indexOf(node);
@@ -315,6 +408,8 @@ export class CanvasRenderer implements Renderer {
315
408
  if (this.rootNode === node) {
316
409
  this.rootNode = null;
317
410
  this.eventManager.setRootNode(null);
411
+ this.scrollManager.setRootNode(null);
412
+ this.selectionManager.setRootNode(null);
318
413
  }
319
414
 
320
415
  // Remove from nodes map
@@ -344,7 +439,30 @@ export class CanvasRenderer implements Renderer {
344
439
  */
345
440
  private render(): void {
346
441
  const startTime = performance.now();
442
+ const dpr = this.options.devicePixelRatio || 1;
443
+
444
+ if (this.options.enableDirtyRects) {
445
+ this.renderWithDirtyRects(dpr);
446
+ } else {
447
+ this.renderFull(dpr);
448
+ }
449
+
450
+ // Performance logging
451
+ if (this.options.logPerformance) {
452
+ const elapsed = performance.now() - startTime;
453
+ this.frameCount++;
454
+ if (performance.now() - this.lastFrameTime > 1000) {
455
+ log.debug(`Canvas FPS: ${this.frameCount}, Last frame: ${elapsed.toFixed(2)}ms`);
456
+ this.frameCount = 0;
457
+ this.lastFrameTime = performance.now();
458
+ }
459
+ }
460
+ }
347
461
 
462
+ /**
463
+ * Full canvas repaint (default behavior when dirty rects disabled)
464
+ */
465
+ private renderFull(dpr: number): void {
348
466
  // Clear canvas
349
467
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
350
468
 
@@ -356,8 +474,6 @@ export class CanvasRenderer implements Renderer {
356
474
 
357
475
  // Layout and paint
358
476
  if (this.rootNode) {
359
- // Compute layout
360
- const dpr = this.options.devicePixelRatio || 1;
361
477
  computeLayout(
362
478
  this.ctx,
363
479
  this.rootNode,
@@ -365,25 +481,76 @@ export class CanvasRenderer implements Renderer {
365
481
  this.canvas.height / dpr
366
482
  );
367
483
 
368
- // Paint
484
+ ScrollManager.updateScrollBounds(this.rootNode);
485
+
369
486
  paintNode(this.ctx, this.rootNode);
370
487
 
371
- // Debug: show layout bounds
372
488
  if (this.options.showLayoutBounds) {
373
489
  this.drawLayoutBounds(this.rootNode);
374
490
  }
375
491
  }
492
+ }
376
493
 
377
- // Performance logging
378
- if (this.options.logPerformance) {
379
- const elapsed = performance.now() - startTime;
380
- this.frameCount++;
381
- if (performance.now() - this.lastFrameTime > 1000) {
382
- log.debug(`Canvas FPS: ${this.frameCount}, Last frame: ${elapsed.toFixed(2)}ms`);
383
- this.frameCount = 0;
384
- this.lastFrameTime = performance.now();
494
+ /**
495
+ * Optimized render that only repaints dirty regions
496
+ */
497
+ private renderWithDirtyRects(dpr: number): void {
498
+ // Always run layout so nodes have up-to-date bounds
499
+ if (this.rootNode) {
500
+ computeLayout(
501
+ this.ctx,
502
+ this.rootNode,
503
+ this.canvas.width / dpr,
504
+ this.canvas.height / dpr
505
+ );
506
+ ScrollManager.updateScrollBounds(this.rootNode);
507
+ }
508
+
509
+ const dirtyRegion = this.dirtyTracker.getDirtyRegion();
510
+ this.dirtyTracker.clear();
511
+
512
+ // Nothing dirty — skip repaint entirely
513
+ if (dirtyRegion === null) {
514
+ return;
515
+ }
516
+
517
+ this.ctx.save();
518
+
519
+ // Clip to dirty region so only affected pixels are drawn
520
+ this.ctx.beginPath();
521
+ this.ctx.rect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
522
+ this.ctx.clip();
523
+
524
+ // Clear only the dirty region
525
+ this.ctx.clearRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
526
+
527
+ // Repaint background within dirty region
528
+ if (this.options.backgroundColor) {
529
+ this.ctx.fillStyle = this.options.backgroundColor;
530
+ this.ctx.fillRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
531
+ }
532
+
533
+ // Repaint the full tree — clip path ensures only dirty pixels are touched
534
+ if (this.rootNode) {
535
+ paintNode(this.ctx, this.rootNode);
536
+
537
+ if (this.options.showLayoutBounds) {
538
+ this.drawLayoutBounds(this.rootNode);
385
539
  }
386
540
  }
541
+
542
+ this.ctx.restore();
543
+
544
+ // Debug: show dirty rect outline
545
+ if (this.options.showDirtyRects) {
546
+ this.ctx.save();
547
+ this.ctx.strokeStyle = "rgba(255, 0, 0, 0.8)";
548
+ this.ctx.lineWidth = 2;
549
+ this.ctx.setLineDash([4, 2]);
550
+ this.ctx.strokeRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
551
+ this.ctx.setLineDash([]);
552
+ this.ctx.restore();
553
+ }
387
554
  }
388
555
 
389
556
  /**
@@ -417,6 +584,9 @@ export class CanvasRenderer implements Renderer {
417
584
  this.rootNode = null;
418
585
  this.nodes.clear();
419
586
  this.eventManager.setRootNode(null);
587
+ this.scrollManager.setRootNode(null);
588
+ this.selectionManager.setRootNode(null);
589
+ this.dirtyTracker.clear();
420
590
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
421
591
  }
422
592
 
@@ -443,6 +613,9 @@ export class CanvasRenderer implements Renderer {
443
613
  cancelAnimationFrame(this.rafId);
444
614
  }
445
615
  this.eventManager.destroy();
616
+ this.scrollManager.destroy();
617
+ this.selectionManager.destroy();
618
+ setSelectionManager(null);
446
619
  this.accessibilityLayer.destroy();
447
620
  }
448
621
  }