@hypen-space/web 0.4.46 → 0.4.81

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
@@ -0,0 +1,729 @@
1
+ /**
2
+ * Scroll Manager
3
+ *
4
+ * Handles touch gestures, wheel events, momentum scrolling, and
5
+ * scrollbar rendering for the canvas renderer. Nodes with
6
+ * overflow: "scroll" | "auto" become scrollable containers.
7
+ */
8
+
9
+ import type { VirtualNode, ScrollState, Point, Rectangle } from "./types.js";
10
+ import { getAbsoluteBounds } from "./utils.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Friction coefficient for momentum deceleration (per ms) */
17
+ const FRICTION = 0.97;
18
+
19
+ /** Minimum velocity before stopping momentum (px/ms) */
20
+ const MIN_VELOCITY = 0.05;
21
+
22
+ /** Elastic overscroll damping factor */
23
+ const ELASTIC_DAMPING = 0.4;
24
+
25
+ /** Snap-back spring constant when released past bounds */
26
+ const SNAP_BACK_SPEED = 0.15;
27
+
28
+ /** Scrollbar thickness in CSS pixels */
29
+ const SCROLLBAR_SIZE = 6;
30
+
31
+ /** Scrollbar minimum thumb length */
32
+ const SCROLLBAR_MIN_THUMB = 24;
33
+
34
+ /** Scrollbar padding from edge */
35
+ const SCROLLBAR_PADDING = 2;
36
+
37
+ /** How quickly the scrollbar fades out (opacity per ms) */
38
+ const SCROLLBAR_FADE_RATE = 0.003;
39
+
40
+ /** Time after scroll stops before scrollbar starts fading (ms) */
41
+ const SCROLLBAR_LINGER = 800;
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // ScrollState helpers
45
+ // ---------------------------------------------------------------------------
46
+
47
+ export function createScrollState(): ScrollState {
48
+ return {
49
+ scrollX: 0,
50
+ scrollY: 0,
51
+ velocityX: 0,
52
+ velocityY: 0,
53
+ scrollWidth: 0,
54
+ scrollHeight: 0,
55
+ touching: false,
56
+ lastTouchTime: 0,
57
+ scrollbarOpacity: 0,
58
+ };
59
+ }
60
+
61
+ /** Returns true if the node is configured as a scrollable container. */
62
+ export function isScrollable(node: VirtualNode): boolean {
63
+ const overflow = node.props.overflow;
64
+ return overflow === "scroll" || overflow === "auto";
65
+ }
66
+
67
+ /** Ensure a scroll state is attached; returns the (possibly new) state. */
68
+ function ensureScrollState(node: VirtualNode): ScrollState {
69
+ if (!node.scrollState) node.scrollState = createScrollState();
70
+ return node.scrollState;
71
+ }
72
+
73
+ /** Maximum scroll offsets for a node (always ≥ 0). */
74
+ function maxScroll(node: VirtualNode): { maxX: number; maxY: number } {
75
+ const ss = node.scrollState;
76
+ if (!ss || !node.layout) return { maxX: 0, maxY: 0 };
77
+ return {
78
+ maxX: Math.max(0, ss.scrollWidth - node.layout.contentWidth),
79
+ maxY: Math.max(0, ss.scrollHeight - node.layout.contentHeight),
80
+ };
81
+ }
82
+
83
+ /** Clamp scroll to valid range (with optional elastic overshoot). */
84
+ function clampScroll(
85
+ value: number,
86
+ max: number,
87
+ elastic: boolean,
88
+ ): number {
89
+ if (!elastic) return Math.max(0, Math.min(value, max));
90
+ // Allow overscroll with damping
91
+ if (value < 0) return value * ELASTIC_DAMPING;
92
+ if (value > max) return max + (value - max) * ELASTIC_DAMPING;
93
+ return value;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // ScrollManager
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export class ScrollManager {
101
+ private canvas: HTMLCanvasElement;
102
+ private rootNode: VirtualNode | null = null;
103
+ private requestRedraw: () => void;
104
+
105
+ // Active touch tracking
106
+ private activeNode: VirtualNode | null = null;
107
+ private lastPointerX = 0;
108
+ private lastPointerY = 0;
109
+ private lastPointerTime = 0;
110
+
111
+ // Momentum animation
112
+ private momentumRaf: number | null = null;
113
+ private lastMomentumTime = 0;
114
+
115
+ // Scrollbar fade timer
116
+ private scrollbarFadeTimer: number | null = null;
117
+ private scrollbarLastActivity = 0;
118
+
119
+ // Bound handlers
120
+ private boundWheel!: (e: WheelEvent) => void;
121
+ private boundTouchStart!: (e: TouchEvent) => void;
122
+ private boundTouchMove!: (e: TouchEvent) => void;
123
+ private boundTouchEnd!: (e: TouchEvent) => void;
124
+ private boundPointerDown!: (e: PointerEvent) => void;
125
+ private boundPointerMove!: (e: PointerEvent) => void;
126
+ private boundPointerUp!: (e: PointerEvent) => void;
127
+
128
+ constructor(
129
+ canvas: HTMLCanvasElement,
130
+ requestRedraw: () => void,
131
+ ) {
132
+ this.canvas = canvas;
133
+ this.requestRedraw = requestRedraw;
134
+ this.setupEventListeners();
135
+ }
136
+
137
+ setRootNode(node: VirtualNode | null): void {
138
+ this.rootNode = node;
139
+ }
140
+
141
+ // -------------------------------------------------------------------------
142
+ // Event listeners
143
+ // -------------------------------------------------------------------------
144
+
145
+ private setupEventListeners(): void {
146
+ this.boundWheel = this.onWheel.bind(this);
147
+ this.boundTouchStart = this.onTouchStart.bind(this);
148
+ this.boundTouchMove = this.onTouchMove.bind(this);
149
+ this.boundTouchEnd = this.onTouchEnd.bind(this);
150
+ this.boundPointerDown = this.onPointerDown.bind(this);
151
+ this.boundPointerMove = this.onPointerMove.bind(this);
152
+ this.boundPointerUp = this.onPointerUp.bind(this);
153
+
154
+ this.canvas.addEventListener("wheel", this.boundWheel, { passive: false });
155
+ this.canvas.addEventListener("touchstart", this.boundTouchStart, { passive: false });
156
+ this.canvas.addEventListener("touchmove", this.boundTouchMove, { passive: false });
157
+ this.canvas.addEventListener("touchend", this.boundTouchEnd);
158
+ this.canvas.addEventListener("pointerdown", this.boundPointerDown);
159
+ this.canvas.addEventListener("pointermove", this.boundPointerMove);
160
+ this.canvas.addEventListener("pointerup", this.boundPointerUp);
161
+ }
162
+
163
+ // -------------------------------------------------------------------------
164
+ // Coordinate helpers
165
+ // -------------------------------------------------------------------------
166
+
167
+ private canvasPoint(clientX: number, clientY: number): Point {
168
+ const rect = this.canvas.getBoundingClientRect();
169
+ const scaleX = this.canvas.width / rect.width;
170
+ const scaleY = this.canvas.height / rect.height;
171
+ return {
172
+ x: (clientX - rect.left) * scaleX,
173
+ y: (clientY - rect.top) * scaleY,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Find the deepest scrollable ancestor at a canvas-space point,
179
+ * accounting for existing scroll offsets.
180
+ */
181
+ private findScrollTarget(point: Point): VirtualNode | null {
182
+ if (!this.rootNode) return null;
183
+ return this.findScrollableAt(this.rootNode, point);
184
+ }
185
+
186
+ private findScrollableAt(node: VirtualNode, point: Point): VirtualNode | null {
187
+ if (!node.visible || !node.layout) return null;
188
+
189
+ const bounds = getScrollAwareBounds(node);
190
+ if (!bounds) return null;
191
+
192
+ if (
193
+ point.x < bounds.x || point.x > bounds.x + bounds.width ||
194
+ point.y < bounds.y || point.y > bounds.y + bounds.height
195
+ ) {
196
+ return null;
197
+ }
198
+
199
+ // Check children first (front-to-back)
200
+ for (let i = node.children.length - 1; i >= 0; i--) {
201
+ const found = this.findScrollableAt(node.children[i], point);
202
+ if (found) return found;
203
+ }
204
+
205
+ if (isScrollable(node)) return node;
206
+ return null;
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Wheel event
211
+ // -------------------------------------------------------------------------
212
+
213
+ /**
214
+ * Walk up from a node to find the nearest scrollable ancestor (excluding
215
+ * the node itself). Returns null if no scrollable ancestor exists.
216
+ */
217
+ private findScrollableAncestor(node: VirtualNode): VirtualNode | null {
218
+ let current = node.parent;
219
+ while (current) {
220
+ if (isScrollable(current)) return current;
221
+ current = current.parent;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Check whether a scrollable node can scroll further in the given delta
228
+ * direction. Returns true if there is remaining scroll range.
229
+ */
230
+ private canScrollInDirection(
231
+ node: VirtualNode,
232
+ dx: number,
233
+ dy: number,
234
+ ): boolean {
235
+ const ss = node.scrollState;
236
+ if (!ss) return false;
237
+ const { maxX, maxY } = maxScroll(node);
238
+
239
+ if (dy < 0 && ss.scrollY > 0) return true;
240
+ if (dy > 0 && ss.scrollY < maxY) return true;
241
+ if (dx < 0 && ss.scrollX > 0) return true;
242
+ if (dx > 0 && ss.scrollX < maxX) return true;
243
+
244
+ return false;
245
+ }
246
+
247
+ private onWheel(e: WheelEvent): void {
248
+ const point = this.canvasPoint(e.clientX, e.clientY);
249
+ let target: VirtualNode | null = this.findScrollTarget(point);
250
+ if (!target) return;
251
+
252
+ let dx = e.deltaX;
253
+ let dy = e.deltaY;
254
+
255
+ // Normalise to pixels (deltaMode: 0=pixel, 1=line, 2=page)
256
+ if (e.deltaMode === 1) { dx *= 20; dy *= 20; }
257
+ if (e.deltaMode === 2) { dx *= 400; dy *= 400; }
258
+
259
+ // Bubble: if the deepest target can't scroll in the requested direction,
260
+ // walk up to find an ancestor that can.
261
+ while (target && !this.canScrollInDirection(target, dx, dy)) {
262
+ target = this.findScrollableAncestor(target);
263
+ }
264
+ if (!target) return;
265
+
266
+ const ss = ensureScrollState(target);
267
+ const { maxX, maxY } = maxScroll(target);
268
+
269
+ const canScrollX = maxX > 0;
270
+ const canScrollY = maxY > 0;
271
+ if (!canScrollX && !canScrollY) return;
272
+
273
+ if (canScrollY) {
274
+ const prev = ss.scrollY;
275
+ ss.scrollY = Math.max(0, Math.min(ss.scrollY + dy, maxY));
276
+ if (ss.scrollY !== prev) e.preventDefault();
277
+ }
278
+ if (canScrollX) {
279
+ const prev = ss.scrollX;
280
+ ss.scrollX = Math.max(0, Math.min(ss.scrollX + dx, maxX));
281
+ if (ss.scrollX !== prev) e.preventDefault();
282
+ }
283
+
284
+ // Stop any momentum
285
+ ss.velocityX = 0;
286
+ ss.velocityY = 0;
287
+
288
+ this.showScrollbar(ss);
289
+ this.requestRedraw();
290
+ }
291
+
292
+ // -------------------------------------------------------------------------
293
+ // Touch events
294
+ // -------------------------------------------------------------------------
295
+
296
+ private onTouchStart(e: TouchEvent): void {
297
+ if (e.touches.length !== 1) return;
298
+ const t = e.touches[0];
299
+ this.startDrag(t.clientX, t.clientY);
300
+ if (this.activeNode) e.preventDefault();
301
+ }
302
+
303
+ private onTouchMove(e: TouchEvent): void {
304
+ if (e.touches.length !== 1 || !this.activeNode) return;
305
+ const t = e.touches[0];
306
+ this.moveDrag(t.clientX, t.clientY);
307
+ e.preventDefault();
308
+ }
309
+
310
+ private onTouchEnd(_e: TouchEvent): void {
311
+ this.endDrag();
312
+ }
313
+
314
+ // -------------------------------------------------------------------------
315
+ // Pointer events (for mouse-drag scrolling on desktop)
316
+ // -------------------------------------------------------------------------
317
+
318
+ private onPointerDown(e: PointerEvent): void {
319
+ // Only handle middle-button or touch pointer
320
+ if (e.pointerType === "touch") return; // already handled by touch events
321
+ if (e.button !== 1) return; // middle-click to scroll
322
+ this.startDrag(e.clientX, e.clientY);
323
+ if (this.activeNode) {
324
+ this.canvas.setPointerCapture(e.pointerId);
325
+ e.preventDefault();
326
+ }
327
+ }
328
+
329
+ private onPointerMove(e: PointerEvent): void {
330
+ if (e.pointerType === "touch" || !this.activeNode) return;
331
+ this.moveDrag(e.clientX, e.clientY);
332
+ }
333
+
334
+ private onPointerUp(e: PointerEvent): void {
335
+ if (e.pointerType === "touch") return;
336
+ this.endDrag();
337
+ }
338
+
339
+ // -------------------------------------------------------------------------
340
+ // Drag helpers (shared between touch and pointer)
341
+ // -------------------------------------------------------------------------
342
+
343
+ private startDrag(clientX: number, clientY: number): void {
344
+ this.stopMomentum();
345
+ const point = this.canvasPoint(clientX, clientY);
346
+ const target = this.findScrollTarget(point);
347
+ if (!target) { this.activeNode = null; return; }
348
+
349
+ const ss = ensureScrollState(target);
350
+ ss.touching = true;
351
+ ss.velocityX = 0;
352
+ ss.velocityY = 0;
353
+
354
+ this.activeNode = target;
355
+ this.lastPointerX = clientX;
356
+ this.lastPointerY = clientY;
357
+ this.lastPointerTime = performance.now();
358
+
359
+ this.showScrollbar(ss);
360
+ }
361
+
362
+ private moveDrag(clientX: number, clientY: number): void {
363
+ const node = this.activeNode;
364
+ if (!node) return;
365
+
366
+ const ss = ensureScrollState(node);
367
+ const { maxX, maxY } = maxScroll(node);
368
+ const now = performance.now();
369
+ const dt = now - this.lastPointerTime;
370
+
371
+ const rect = this.canvas.getBoundingClientRect();
372
+ const scaleX = this.canvas.width / rect.width;
373
+ const scaleY = this.canvas.height / rect.height;
374
+
375
+ const dx = -(clientX - this.lastPointerX) * scaleX;
376
+ const dy = -(clientY - this.lastPointerY) * scaleY;
377
+
378
+ ss.scrollX = clampScroll(ss.scrollX + dx, maxX, true);
379
+ ss.scrollY = clampScroll(ss.scrollY + dy, maxY, true);
380
+
381
+ // Track velocity (exponential moving average)
382
+ if (dt > 0) {
383
+ const alpha = 0.4;
384
+ ss.velocityX = alpha * (dx / dt) + (1 - alpha) * ss.velocityX;
385
+ ss.velocityY = alpha * (dy / dt) + (1 - alpha) * ss.velocityY;
386
+ }
387
+
388
+ this.lastPointerX = clientX;
389
+ this.lastPointerY = clientY;
390
+ this.lastPointerTime = now;
391
+
392
+ this.showScrollbar(ss);
393
+ this.requestRedraw();
394
+ }
395
+
396
+ private endDrag(): void {
397
+ const node = this.activeNode;
398
+ if (!node) return;
399
+
400
+ const ss = ensureScrollState(node);
401
+ ss.touching = false;
402
+ this.activeNode = null;
403
+
404
+ // If overscrolled, snap back. Otherwise start momentum.
405
+ const { maxX, maxY } = maxScroll(node);
406
+ const overscrolled =
407
+ ss.scrollX < 0 || ss.scrollX > maxX ||
408
+ ss.scrollY < 0 || ss.scrollY > maxY;
409
+
410
+ if (overscrolled) {
411
+ this.startSnapBack(node);
412
+ } else {
413
+ this.startMomentum(node);
414
+ }
415
+ }
416
+
417
+ // -------------------------------------------------------------------------
418
+ // Momentum animation
419
+ // -------------------------------------------------------------------------
420
+
421
+ private startMomentum(node: VirtualNode): void {
422
+ this.stopMomentum();
423
+ const ss = node.scrollState;
424
+ if (!ss) return;
425
+
426
+ const absVelocity = Math.abs(ss.velocityX) + Math.abs(ss.velocityY);
427
+ if (absVelocity < MIN_VELOCITY) {
428
+ this.beginScrollbarFade(ss);
429
+ return;
430
+ }
431
+
432
+ this.lastMomentumTime = performance.now();
433
+
434
+ const tick = () => {
435
+ const now = performance.now();
436
+ const dt = now - this.lastMomentumTime;
437
+ this.lastMomentumTime = now;
438
+
439
+ const { maxX, maxY } = maxScroll(node);
440
+
441
+ ss.scrollX += ss.velocityX * dt;
442
+ ss.scrollY += ss.velocityY * dt;
443
+
444
+ // Decelerate
445
+ const factor = Math.pow(FRICTION, dt);
446
+ ss.velocityX *= factor;
447
+ ss.velocityY *= factor;
448
+
449
+ // Clamp to bounds (hard stop at edges)
450
+ let stopped = false;
451
+ if (ss.scrollX < 0) { ss.scrollX = 0; ss.velocityX = 0; stopped = true; }
452
+ if (ss.scrollX > maxX) { ss.scrollX = maxX; ss.velocityX = 0; stopped = true; }
453
+ if (ss.scrollY < 0) { ss.scrollY = 0; ss.velocityY = 0; stopped = true; }
454
+ if (ss.scrollY > maxY) { ss.scrollY = maxY; ss.velocityY = 0; stopped = true; }
455
+
456
+ const v = Math.abs(ss.velocityX) + Math.abs(ss.velocityY);
457
+ this.requestRedraw();
458
+
459
+ if (v < MIN_VELOCITY || stopped) {
460
+ this.momentumRaf = null;
461
+ this.beginScrollbarFade(ss);
462
+ } else {
463
+ this.momentumRaf = requestAnimationFrame(tick);
464
+ }
465
+ };
466
+
467
+ this.momentumRaf = requestAnimationFrame(tick);
468
+ }
469
+
470
+ private startSnapBack(node: VirtualNode): void {
471
+ this.stopMomentum();
472
+ const ss = node.scrollState;
473
+ if (!ss) return;
474
+
475
+ const tick = () => {
476
+ const { maxX, maxY } = maxScroll(node);
477
+ let targetX = ss.scrollX;
478
+ let targetY = ss.scrollY;
479
+
480
+ if (targetX < 0) targetX = 0;
481
+ else if (targetX > maxX) targetX = maxX;
482
+
483
+ if (targetY < 0) targetY = 0;
484
+ else if (targetY > maxY) targetY = maxY;
485
+
486
+ const dx = targetX - ss.scrollX;
487
+ const dy = targetY - ss.scrollY;
488
+
489
+ if (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) {
490
+ ss.scrollX = targetX;
491
+ ss.scrollY = targetY;
492
+ this.momentumRaf = null;
493
+ this.beginScrollbarFade(ss);
494
+ this.requestRedraw();
495
+ return;
496
+ }
497
+
498
+ ss.scrollX += dx * SNAP_BACK_SPEED;
499
+ ss.scrollY += dy * SNAP_BACK_SPEED;
500
+ this.requestRedraw();
501
+ this.momentumRaf = requestAnimationFrame(tick);
502
+ };
503
+
504
+ this.momentumRaf = requestAnimationFrame(tick);
505
+ }
506
+
507
+ private stopMomentum(): void {
508
+ if (this.momentumRaf !== null) {
509
+ cancelAnimationFrame(this.momentumRaf);
510
+ this.momentumRaf = null;
511
+ }
512
+ }
513
+
514
+ // -------------------------------------------------------------------------
515
+ // Scrollbar visibility
516
+ // -------------------------------------------------------------------------
517
+
518
+ private showScrollbar(ss: ScrollState): void {
519
+ ss.scrollbarOpacity = 1;
520
+ this.scrollbarLastActivity = performance.now();
521
+ if (this.scrollbarFadeTimer !== null) {
522
+ clearInterval(this.scrollbarFadeTimer);
523
+ this.scrollbarFadeTimer = null;
524
+ }
525
+ }
526
+
527
+ private beginScrollbarFade(ss: ScrollState): void {
528
+ if (this.scrollbarFadeTimer !== null) return;
529
+ this.scrollbarLastActivity = performance.now();
530
+
531
+ this.scrollbarFadeTimer = setInterval(() => {
532
+ const elapsed = performance.now() - this.scrollbarLastActivity;
533
+ if (elapsed < SCROLLBAR_LINGER) return;
534
+
535
+ ss.scrollbarOpacity -= SCROLLBAR_FADE_RATE * 16; // ~16ms per interval tick
536
+ if (ss.scrollbarOpacity <= 0) {
537
+ ss.scrollbarOpacity = 0;
538
+ clearInterval(this.scrollbarFadeTimer!);
539
+ this.scrollbarFadeTimer = null;
540
+ }
541
+ this.requestRedraw();
542
+ }, 16) as unknown as number;
543
+ }
544
+
545
+ // -------------------------------------------------------------------------
546
+ // Scrollbar painting (called from paint pipeline)
547
+ // -------------------------------------------------------------------------
548
+
549
+ /**
550
+ * Draw scrollbar indicators for a scrollable node.
551
+ * Call this after painting the node's children, while the clip is still active.
552
+ */
553
+ static paintScrollbars(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
554
+ const ss = node.scrollState;
555
+ if (!ss || ss.scrollbarOpacity <= 0) return;
556
+ if (!node.layout) return;
557
+
558
+ const layout = node.layout;
559
+ const x = layout.x;
560
+ const y = layout.y;
561
+ const w = layout.width;
562
+ const h = layout.height;
563
+
564
+ const alpha = ss.scrollbarOpacity * 0.5;
565
+
566
+ // Vertical scrollbar
567
+ if (ss.scrollHeight > layout.contentHeight) {
568
+ const trackHeight = h - SCROLLBAR_PADDING * 2;
569
+ const ratio = layout.contentHeight / ss.scrollHeight;
570
+ const thumbHeight = Math.max(SCROLLBAR_MIN_THUMB, trackHeight * ratio);
571
+ const { maxY } = { maxY: Math.max(0, ss.scrollHeight - layout.contentHeight) };
572
+ const scrollRatio = maxY > 0 ? ss.scrollY / maxY : 0;
573
+ const thumbY = SCROLLBAR_PADDING + scrollRatio * (trackHeight - thumbHeight);
574
+
575
+ ctx.save();
576
+ ctx.globalAlpha = alpha;
577
+ ctx.fillStyle = "#888";
578
+ drawPill(
579
+ ctx,
580
+ x + w - SCROLLBAR_SIZE - SCROLLBAR_PADDING,
581
+ y + thumbY,
582
+ SCROLLBAR_SIZE,
583
+ thumbHeight,
584
+ );
585
+ ctx.fill();
586
+ ctx.restore();
587
+ }
588
+
589
+ // Horizontal scrollbar
590
+ if (ss.scrollWidth > layout.contentWidth) {
591
+ const trackWidth = w - SCROLLBAR_PADDING * 2;
592
+ const ratio = layout.contentWidth / ss.scrollWidth;
593
+ const thumbWidth = Math.max(SCROLLBAR_MIN_THUMB, trackWidth * ratio);
594
+ const { maxX } = { maxX: Math.max(0, ss.scrollWidth - layout.contentWidth) };
595
+ const scrollRatio = maxX > 0 ? ss.scrollX / maxX : 0;
596
+ const thumbX = SCROLLBAR_PADDING + scrollRatio * (trackWidth - thumbWidth);
597
+
598
+ ctx.save();
599
+ ctx.globalAlpha = alpha;
600
+ ctx.fillStyle = "#888";
601
+ drawPill(
602
+ ctx,
603
+ x + thumbX,
604
+ y + h - SCROLLBAR_SIZE - SCROLLBAR_PADDING,
605
+ thumbWidth,
606
+ SCROLLBAR_SIZE,
607
+ );
608
+ ctx.fill();
609
+ ctx.restore();
610
+ }
611
+ }
612
+
613
+ // -------------------------------------------------------------------------
614
+ // Layout integration
615
+ // -------------------------------------------------------------------------
616
+
617
+ /**
618
+ * After layout is computed, calculate the total content size of
619
+ * scrollable containers so we know the max scroll range.
620
+ */
621
+ static updateScrollBounds(node: VirtualNode): void {
622
+ if (!node.layout) return;
623
+
624
+ // Recurse first so children have their layouts
625
+ for (const child of node.children) {
626
+ ScrollManager.updateScrollBounds(child);
627
+ }
628
+
629
+ if (!isScrollable(node)) return;
630
+
631
+ const ss = ensureScrollState(node);
632
+ let maxRight = 0;
633
+ let maxBottom = 0;
634
+
635
+ for (const child of node.children) {
636
+ if (!child.layout) continue;
637
+ // Child positions are absolute; make them relative to this container
638
+ const relX = child.layout.x - node.layout.x - node.layout.contentX;
639
+ const relY = child.layout.y - node.layout.y - node.layout.contentY;
640
+ maxRight = Math.max(maxRight, relX + child.layout.width + child.layout.margin.right);
641
+ maxBottom = Math.max(maxBottom, relY + child.layout.height + child.layout.margin.bottom);
642
+ }
643
+
644
+ ss.scrollWidth = maxRight;
645
+ ss.scrollHeight = maxBottom;
646
+
647
+ // Clamp current scroll to new bounds
648
+ const { maxX, maxY } = maxScroll(node);
649
+ ss.scrollX = Math.max(0, Math.min(ss.scrollX, maxX));
650
+ ss.scrollY = Math.max(0, Math.min(ss.scrollY, maxY));
651
+ }
652
+
653
+ // -------------------------------------------------------------------------
654
+ // Cleanup
655
+ // -------------------------------------------------------------------------
656
+
657
+ destroy(): void {
658
+ this.stopMomentum();
659
+ if (this.scrollbarFadeTimer !== null) {
660
+ clearInterval(this.scrollbarFadeTimer);
661
+ this.scrollbarFadeTimer = null;
662
+ }
663
+ this.canvas.removeEventListener("wheel", this.boundWheel);
664
+ this.canvas.removeEventListener("touchstart", this.boundTouchStart);
665
+ this.canvas.removeEventListener("touchmove", this.boundTouchMove);
666
+ this.canvas.removeEventListener("touchend", this.boundTouchEnd);
667
+ this.canvas.removeEventListener("pointerdown", this.boundPointerDown);
668
+ this.canvas.removeEventListener("pointermove", this.boundPointerMove);
669
+ this.canvas.removeEventListener("pointerup", this.boundPointerUp);
670
+ this.rootNode = null;
671
+ this.activeNode = null;
672
+ }
673
+ }
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Scroll-aware coordinate helpers (used by hit testing)
677
+ // ---------------------------------------------------------------------------
678
+
679
+ /**
680
+ * Get absolute bounds for a node, accounting for scroll offsets of all
681
+ * ancestors. This is the scroll-aware replacement for getAbsoluteBounds.
682
+ */
683
+ export function getScrollAwareBounds(node: VirtualNode): Rectangle | null {
684
+ if (!node.layout) return null;
685
+
686
+ let x = node.layout.x;
687
+ let y = node.layout.y;
688
+
689
+ let current = node.parent;
690
+ while (current && current.layout) {
691
+ x += current.layout.contentX;
692
+ y += current.layout.contentY;
693
+
694
+ // Apply ancestor scroll offset
695
+ if (current.scrollState) {
696
+ x -= current.scrollState.scrollX;
697
+ y -= current.scrollState.scrollY;
698
+ }
699
+
700
+ current = current.parent;
701
+ }
702
+
703
+ return { x, y, width: node.layout.width, height: node.layout.height };
704
+ }
705
+
706
+ // ---------------------------------------------------------------------------
707
+ // Drawing helpers
708
+ // ---------------------------------------------------------------------------
709
+
710
+ function drawPill(
711
+ ctx: CanvasRenderingContext2D,
712
+ x: number,
713
+ y: number,
714
+ w: number,
715
+ h: number,
716
+ ): void {
717
+ const r = Math.min(w, h) / 2;
718
+ ctx.beginPath();
719
+ ctx.moveTo(x + r, y);
720
+ ctx.lineTo(x + w - r, y);
721
+ ctx.arcTo(x + w, y, x + w, y + r, r);
722
+ ctx.lineTo(x + w, y + h - r);
723
+ ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
724
+ ctx.lineTo(x + r, y + h);
725
+ ctx.arcTo(x, y + h, x, y + h - r, r);
726
+ ctx.lineTo(x, y + r);
727
+ ctx.arcTo(x, y, x + r, y, r);
728
+ ctx.closePath();
729
+ }