@hypen-space/web 0.4.82 → 0.4.83
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.
- package/dist/canvas/events.d.ts +30 -2
- package/dist/canvas/events.js +112 -30
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/input.d.ts +15 -2
- package/dist/canvas/input.js +43 -24
- package/dist/canvas/input.js.map +1 -1
- package/dist/canvas/layout.js +479 -96
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +4 -0
- package/dist/canvas/paint.js +141 -29
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/props.d.ts +55 -0
- package/dist/canvas/props.js +121 -0
- package/dist/canvas/props.js.map +1 -0
- package/dist/canvas/renderer.d.ts +13 -0
- package/dist/canvas/renderer.js +109 -11
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +27 -3
- package/dist/canvas/scroll.js +66 -26
- package/dist/canvas/scroll.js.map +1 -1
- package/dist/canvas/selection.js +3 -4
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/utils.d.ts +23 -3
- package/dist/canvas/utils.js +90 -30
- package/dist/canvas/utils.js.map +1 -1
- package/dist/dom/applicators/border.js +9 -0
- package/dist/dom/applicators/border.js.map +1 -1
- package/dist/dom/applicators/font.js +18 -1
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/layout.js +44 -8
- package/dist/dom/applicators/layout.js.map +1 -1
- package/dist/dom/applicators/margin.js +18 -53
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +21 -33
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/applicators/typography.js +13 -1
- package/dist/dom/applicators/typography.js.map +1 -1
- package/dist/dom/components/column.d.ts +11 -2
- package/dist/dom/components/column.js +12 -4
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/image.js +9 -3
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/input.js +11 -0
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/textarea.js +8 -0
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/renderer.d.ts +18 -0
- package/dist/dom/renderer.js +87 -3
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +2 -2
- package/src/canvas/PARITY.md +254 -0
- package/src/canvas/events.ts +113 -32
- package/src/canvas/input.ts +44 -25
- package/src/canvas/layout.ts +492 -91
- package/src/canvas/paint.ts +143 -29
- package/src/canvas/props.ts +117 -0
- package/src/canvas/renderer.ts +132 -11
- package/src/canvas/scroll.ts +62 -29
- package/src/canvas/selection.ts +3 -4
- package/src/canvas/utils.ts +83 -31
- package/src/dom/applicators/border.ts +9 -0
- package/src/dom/applicators/font.ts +16 -1
- package/src/dom/applicators/layout.ts +34 -7
- package/src/dom/applicators/margin.ts +16 -43
- package/src/dom/applicators/padding.ts +19 -27
- package/src/dom/applicators/typography.ts +11 -1
- package/src/dom/components/column.ts +12 -4
- package/src/dom/components/image.ts +8 -3
- package/src/dom/components/input.ts +11 -0
- package/src/dom/components/textarea.ts +8 -0
- package/src/dom/renderer.ts +92 -3
package/src/canvas/paint.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { 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 { cssLengthToPx } from "./utils.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Module-level reference to the active SelectionManager so paintText
|
|
@@ -151,9 +152,20 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
151
152
|
})
|
|
152
153
|
: node.children;
|
|
153
154
|
|
|
155
|
+
// Paint flow children first, then absolute-positioned overlays on top.
|
|
156
|
+
// CSS uses `z-index` to order absolute siblings; Canvas just paints in
|
|
157
|
+
// tree order, so a `position: absolute` header declared FIRST in the
|
|
158
|
+
// tree (Story uses this for its close button) gets covered by the
|
|
159
|
+
// following in-flow Image. Push absolute kids to the end so they land
|
|
160
|
+
// on top — same effect as `z-index: auto` painting absolute after flow.
|
|
161
|
+
const flowKids: VirtualNode[] = [];
|
|
162
|
+
const overlayKids: VirtualNode[] = [];
|
|
154
163
|
for (const child of childrenToPaint) {
|
|
155
|
-
|
|
164
|
+
if (child.props.position === "absolute") overlayKids.push(child);
|
|
165
|
+
else flowKids.push(child);
|
|
156
166
|
}
|
|
167
|
+
for (const child of flowKids) paintNode(ctx, child);
|
|
168
|
+
for (const child of overlayKids) paintNode(ctx, child);
|
|
157
169
|
|
|
158
170
|
// Undo scroll translation before drawing scrollbars
|
|
159
171
|
if ((node as any)._scrollRestore) {
|
|
@@ -261,14 +273,14 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
261
273
|
|
|
262
274
|
let text = String(props[0] || props.text || "");
|
|
263
275
|
const color = props.color || "#000000";
|
|
264
|
-
const fontSize =
|
|
276
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
265
277
|
const fontWeight = props.fontWeight || "normal";
|
|
266
278
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
267
279
|
const textAlign = props.textAlign || "left";
|
|
268
|
-
const lineHeight =
|
|
280
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
269
281
|
const textDecoration = props.textDecoration || "none";
|
|
270
282
|
const textTransform = props.textTransform || "none";
|
|
271
|
-
const letterSpacing =
|
|
283
|
+
const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
|
|
272
284
|
|
|
273
285
|
// Apply text transform
|
|
274
286
|
if (textTransform === "uppercase") {
|
|
@@ -366,13 +378,29 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
366
378
|
applyShadow(ctx, shadow);
|
|
367
379
|
}
|
|
368
380
|
|
|
369
|
-
// Background color based on state
|
|
370
|
-
|
|
381
|
+
// Background color based on state.
|
|
382
|
+
//
|
|
383
|
+
// Only the default-blue button derives a default hover/focus shade. When a
|
|
384
|
+
// caller supplies `backgroundColor` (including "transparent" for icon-style
|
|
385
|
+
// buttons), swap to hover/focus only if they _also_ supply an explicit
|
|
386
|
+
// hoverColor/focusColor — otherwise the caller's intent wins and we leave
|
|
387
|
+
// the base colour untouched. Without this, transparent nav buttons would
|
|
388
|
+
// flash a blue rectangle on hover.
|
|
389
|
+
const explicitBg = props.backgroundColor !== undefined;
|
|
390
|
+
let backgroundColor = explicitBg ? props.backgroundColor : "#007bff";
|
|
371
391
|
if (node.hovered) {
|
|
372
|
-
|
|
392
|
+
if (props.hoverColor !== undefined) {
|
|
393
|
+
backgroundColor = props.hoverColor;
|
|
394
|
+
} else if (!explicitBg) {
|
|
395
|
+
backgroundColor = "#0056b3";
|
|
396
|
+
}
|
|
373
397
|
}
|
|
374
398
|
if (node.focused) {
|
|
375
|
-
|
|
399
|
+
if (props.focusColor !== undefined) {
|
|
400
|
+
backgroundColor = props.focusColor;
|
|
401
|
+
} else if (!explicitBg) {
|
|
402
|
+
backgroundColor = "#004085";
|
|
403
|
+
}
|
|
376
404
|
}
|
|
377
405
|
|
|
378
406
|
// Support gradients for button background
|
|
@@ -441,10 +469,10 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
441
469
|
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
442
470
|
|
|
443
471
|
if (text) {
|
|
444
|
-
const fontSize =
|
|
472
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
445
473
|
const fontWeight = props.fontWeight || "normal";
|
|
446
474
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
447
|
-
const lineHeight =
|
|
475
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
448
476
|
|
|
449
477
|
renderText(
|
|
450
478
|
ctx,
|
|
@@ -478,6 +506,28 @@ const imageFailCache = new Map<string, { failedAt: number; attempts: number }>()
|
|
|
478
506
|
const IMAGE_FAIL_COOLDOWN_MS = 30_000;
|
|
479
507
|
const IMAGE_MAX_ATTEMPTS = 3;
|
|
480
508
|
|
|
509
|
+
/**
|
|
510
|
+
* Cached intrinsic aspect ratios (width / height) for decoded images. The
|
|
511
|
+
* layout engine reads this to give an Image with only one declared dimension
|
|
512
|
+
* the right size on the other axis (instead of stretching to Taffy auto).
|
|
513
|
+
*
|
|
514
|
+
* Populated when an Image's onload fires; tests can seed it via
|
|
515
|
+
* `setImageNaturalSize` to assert the layout path without a real DOM.
|
|
516
|
+
*/
|
|
517
|
+
const imageNaturalAspect = new Map<string, number>();
|
|
518
|
+
|
|
519
|
+
/** Returns width/height of the cached image, or null if unknown. */
|
|
520
|
+
export function getImageNaturalAspect(src: string): number | null {
|
|
521
|
+
return imageNaturalAspect.get(src) ?? null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Test helper: seed the intrinsic-size cache without loading a real image. */
|
|
525
|
+
export function setImageNaturalSize(src: string, width: number, height: number): void {
|
|
526
|
+
if (width > 0 && height > 0) {
|
|
527
|
+
imageNaturalAspect.set(src, width / height);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
481
531
|
/**
|
|
482
532
|
* Evict oldest entries from a Map when it exceeds the given maximum size.
|
|
483
533
|
*/
|
|
@@ -520,6 +570,7 @@ export function clearImageCache(): void {
|
|
|
520
570
|
imageCache.clear();
|
|
521
571
|
imagePending.clear();
|
|
522
572
|
imageFailCache.clear();
|
|
573
|
+
imageNaturalAspect.clear();
|
|
523
574
|
}
|
|
524
575
|
|
|
525
576
|
/**
|
|
@@ -536,11 +587,29 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
536
587
|
const y = layout.y;
|
|
537
588
|
const width = layout.width;
|
|
538
589
|
const height = layout.height;
|
|
590
|
+
const radius = layout.border.radius;
|
|
591
|
+
|
|
592
|
+
// Clip to the rounded box so `rounded-full` avatars actually paint as
|
|
593
|
+
// circles. Without this, avatars and post-header photos paint as
|
|
594
|
+
// squares behind their (correctly-rounded) story-ring border.
|
|
595
|
+
const clipped = radius > 0 && width > 0 && height > 0;
|
|
596
|
+
if (clipped) {
|
|
597
|
+
ctx.save();
|
|
598
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
599
|
+
ctx.clip();
|
|
600
|
+
}
|
|
539
601
|
|
|
540
602
|
const cached = imageCache.get(src);
|
|
541
603
|
if (cached && cached.complete && cached.naturalWidth > 0) {
|
|
542
|
-
//
|
|
543
|
-
|
|
604
|
+
// `objectFit: cover` is the social-feed default — fill the box without
|
|
605
|
+
// squashing. Anything else (or unspecified) falls back to the prior
|
|
606
|
+
// stretch behaviour for back-compat.
|
|
607
|
+
if (props.objectFit === "cover") {
|
|
608
|
+
drawImageCover(ctx, cached, x, y, width, height);
|
|
609
|
+
} else {
|
|
610
|
+
ctx.drawImage(cached, x, y, width, height);
|
|
611
|
+
}
|
|
612
|
+
if (clipped) ctx.restore();
|
|
544
613
|
return;
|
|
545
614
|
}
|
|
546
615
|
|
|
@@ -562,11 +631,13 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
562
631
|
if (failEntry) {
|
|
563
632
|
if (failEntry.attempts >= IMAGE_MAX_ATTEMPTS || Date.now() - failEntry.failedAt < IMAGE_FAIL_COOLDOWN_MS) {
|
|
564
633
|
ctx.fillText("Failed", x + width / 2, y + height / 2);
|
|
634
|
+
if (clipped) ctx.restore();
|
|
565
635
|
return;
|
|
566
636
|
}
|
|
567
637
|
}
|
|
568
638
|
|
|
569
639
|
ctx.fillText("Loading...", x + width / 2, y + height / 2);
|
|
640
|
+
if (clipped) ctx.restore();
|
|
570
641
|
|
|
571
642
|
// Start loading if not already pending
|
|
572
643
|
if (!imagePending.has(src)) {
|
|
@@ -578,6 +649,9 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
578
649
|
img.onload = () => {
|
|
579
650
|
imageInFlight.delete(src);
|
|
580
651
|
imageCache.set(src, img);
|
|
652
|
+
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
653
|
+
imageNaturalAspect.set(src, img.naturalWidth / img.naturalHeight);
|
|
654
|
+
}
|
|
581
655
|
imagePending.delete(src);
|
|
582
656
|
imageFailCache.delete(src);
|
|
583
657
|
evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
|
|
@@ -599,7 +673,45 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
599
673
|
}
|
|
600
674
|
|
|
601
675
|
/**
|
|
602
|
-
* Draw
|
|
676
|
+
* Draw an image with `object-fit: cover` semantics — fill the entire
|
|
677
|
+
* destination box without distortion, cropping the longer source axis.
|
|
678
|
+
*/
|
|
679
|
+
function drawImageCover(
|
|
680
|
+
ctx: CanvasRenderingContext2D,
|
|
681
|
+
img: HTMLImageElement,
|
|
682
|
+
dx: number,
|
|
683
|
+
dy: number,
|
|
684
|
+
dw: number,
|
|
685
|
+
dh: number,
|
|
686
|
+
): void {
|
|
687
|
+
const sw = img.naturalWidth;
|
|
688
|
+
const sh = img.naturalHeight;
|
|
689
|
+
if (sw <= 0 || sh <= 0) {
|
|
690
|
+
ctx.drawImage(img, dx, dy, dw, dh);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
const srcAspect = sw / sh;
|
|
694
|
+
const dstAspect = dw / dh;
|
|
695
|
+
let sx = 0, sy = 0, sWidth = sw, sHeight = sh;
|
|
696
|
+
if (srcAspect > dstAspect) {
|
|
697
|
+
// Source is wider — crop horizontally.
|
|
698
|
+
sWidth = sh * dstAspect;
|
|
699
|
+
sx = (sw - sWidth) / 2;
|
|
700
|
+
} else if (srcAspect < dstAspect) {
|
|
701
|
+
// Source is taller — crop vertically.
|
|
702
|
+
sHeight = sw / dstAspect;
|
|
703
|
+
sy = (sh - sHeight) / 2;
|
|
704
|
+
}
|
|
705
|
+
ctx.drawImage(img, sx, sy, sWidth, sHeight, dx, dy, dw, dh);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Draw rounded rectangle path.
|
|
710
|
+
*
|
|
711
|
+
* Clamps the radius to half the smallest side — Tailwind's `rounded-full`
|
|
712
|
+
* lands on the engine as `border-radius: 9999px`, and without clamping the
|
|
713
|
+
* arcs wrap outside the box and fill a giant area (a 20×20 badge with
|
|
714
|
+
* radius 9999 paints as ~470×250 of background colour, swamping the row).
|
|
603
715
|
*/
|
|
604
716
|
function drawRoundedRect(
|
|
605
717
|
ctx: CanvasRenderingContext2D,
|
|
@@ -609,21 +721,23 @@ function drawRoundedRect(
|
|
|
609
721
|
height: number,
|
|
610
722
|
radius: number
|
|
611
723
|
): void {
|
|
612
|
-
if (radius <= 0) {
|
|
724
|
+
if (radius <= 0 || width <= 0 || height <= 0) {
|
|
613
725
|
ctx.rect(x, y, width, height);
|
|
614
726
|
return;
|
|
615
727
|
}
|
|
616
728
|
|
|
729
|
+
const r = Math.min(radius, width / 2, height / 2);
|
|
730
|
+
|
|
617
731
|
ctx.beginPath();
|
|
618
|
-
ctx.moveTo(x +
|
|
619
|
-
ctx.lineTo(x + width -
|
|
620
|
-
ctx.arcTo(x + width, y, x + width, y +
|
|
621
|
-
ctx.lineTo(x + width, y + height -
|
|
622
|
-
ctx.arcTo(x + width, y + height, x + width -
|
|
623
|
-
ctx.lineTo(x +
|
|
624
|
-
ctx.arcTo(x, y + height, x, y + height -
|
|
625
|
-
ctx.lineTo(x, y +
|
|
626
|
-
ctx.arcTo(x, y, x +
|
|
732
|
+
ctx.moveTo(x + r, y);
|
|
733
|
+
ctx.lineTo(x + width - r, y);
|
|
734
|
+
ctx.arcTo(x + width, y, x + width, y + r, r);
|
|
735
|
+
ctx.lineTo(x + width, y + height - r);
|
|
736
|
+
ctx.arcTo(x + width, y + height, x + width - r, y + height, r);
|
|
737
|
+
ctx.lineTo(x + r, y + height);
|
|
738
|
+
ctx.arcTo(x, y + height, x, y + height - r, r);
|
|
739
|
+
ctx.lineTo(x, y + r);
|
|
740
|
+
ctx.arcTo(x, y, x + r, y, r);
|
|
627
741
|
ctx.closePath();
|
|
628
742
|
}
|
|
629
743
|
|
|
@@ -847,7 +961,7 @@ function paintDivider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
847
961
|
|
|
848
962
|
const orientation = props.orientation || "horizontal";
|
|
849
963
|
const color = props.color || props.backgroundColor || "#e0e0e0";
|
|
850
|
-
const thickness =
|
|
964
|
+
const thickness = cssLengthToPx(props.thickness) ?? 1;
|
|
851
965
|
|
|
852
966
|
ctx.strokeStyle = color;
|
|
853
967
|
ctx.lineWidth = thickness;
|
|
@@ -881,7 +995,7 @@ function paintCheckbox(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
881
995
|
const checkedValue = props.checked !== undefined ? props.checked : props.value;
|
|
882
996
|
const checked = checkedValue === true || checkedValue === "true" || (checkedValue !== false && checkedValue !== "false" && !!checkedValue);
|
|
883
997
|
|
|
884
|
-
const radius =
|
|
998
|
+
const radius = cssLengthToPx(props.borderRadius) ?? 2;
|
|
885
999
|
|
|
886
1000
|
// Background
|
|
887
1001
|
const bgColor = checked ? (props.checkedColor || "#007bff") : (props.backgroundColor || "#ffffff");
|
|
@@ -1017,8 +1131,8 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1017
1131
|
const value = parseFloat(props.value) || min;
|
|
1018
1132
|
const percentage = (value - min) / (max - min);
|
|
1019
1133
|
|
|
1020
|
-
const trackHeight =
|
|
1021
|
-
const thumbSize =
|
|
1134
|
+
const trackHeight = cssLengthToPx(props.trackHeight) ?? 4;
|
|
1135
|
+
const thumbSize = cssLengthToPx(props.thumbSize) ?? 16;
|
|
1022
1136
|
const trackY = y + (height - trackHeight) / 2;
|
|
1023
1137
|
|
|
1024
1138
|
// Track background
|
|
@@ -1118,7 +1232,7 @@ function paintSpinner(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1118
1232
|
const centerX = layout.x + size / 2;
|
|
1119
1233
|
const centerY = layout.y + size / 2;
|
|
1120
1234
|
const radius = size / 2 - 4;
|
|
1121
|
-
const thickness =
|
|
1235
|
+
const thickness = cssLengthToPx(props.thickness) ?? 4;
|
|
1122
1236
|
const color = props.color || "#007bff";
|
|
1123
1237
|
|
|
1124
1238
|
// Use timestamp for animation if available
|
|
@@ -1387,7 +1501,7 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1387
1501
|
|
|
1388
1502
|
const text = String(props[0] || props.text || "");
|
|
1389
1503
|
const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
|
|
1390
|
-
const fontSize =
|
|
1504
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
1391
1505
|
const fontWeight = props.fontWeight || "normal";
|
|
1392
1506
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
1393
1507
|
const textDecoration = props.textDecoration !== undefined ? props.textDecoration : "underline";
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas applicator-prop normalisation.
|
|
3
|
+
*
|
|
4
|
+
* The engine emits props using the applicator-argument naming scheme:
|
|
5
|
+
* - single-positional: `flex.0`, `padding.0`, `width.0`
|
|
6
|
+
* - multi-argument: `onClick.0`, `onClick.to`, `onClick.postId`
|
|
7
|
+
*
|
|
8
|
+
* The DOM renderer groups these at apply time via ApplicatorRegistry so
|
|
9
|
+
* downstream code can read `style.flex`, handler payloads, etc. The canvas
|
|
10
|
+
* layout (`layout.ts`) and paint (`paint.ts`) expect the same flat / aggregate
|
|
11
|
+
* shape but don't run an applicator pipeline, so without normalisation every
|
|
12
|
+
* size/flex/padding arriving from the engine is silently ignored — nodes
|
|
13
|
+
* collapse to Taffy auto sizing and images paint at full canvas bounds.
|
|
14
|
+
*
|
|
15
|
+
* We mirror the DOM grouping here: keep the raw `"base.key"` entries on the
|
|
16
|
+
* node (so follow-up SetProp patches can re-derive the aggregate) and
|
|
17
|
+
* additionally populate the bare `base` key with either the scalar value
|
|
18
|
+
* (single `.0`) or an aggregate object (multiple args).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Return the applicator base name for a namespaced key, or null if it's flat. */
|
|
22
|
+
export function parseApplicatorBase(name: string): string | null {
|
|
23
|
+
const dot = name.indexOf(".");
|
|
24
|
+
if (dot <= 0) return null;
|
|
25
|
+
return name.slice(0, dot);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rebuild the derived flat/aggregate entry for `baseName` from all
|
|
30
|
+
* `"baseName.*"` keys currently on `props`. Mutates `props` in place.
|
|
31
|
+
*
|
|
32
|
+
* - Zero matching dotted keys: delete `props[baseName]` (any previously
|
|
33
|
+
* derived value is now stale).
|
|
34
|
+
* - Exactly one key, numeric (`baseName.0`): flatten to the scalar value.
|
|
35
|
+
* - Anything else: store as an object keyed by the arg names.
|
|
36
|
+
*/
|
|
37
|
+
export function refreshApplicator(props: Record<string, any>, baseName: string): void {
|
|
38
|
+
const prefix = baseName + ".";
|
|
39
|
+
const args: Record<string, any> = {};
|
|
40
|
+
let count = 0;
|
|
41
|
+
for (const key of Object.keys(props)) {
|
|
42
|
+
if (!key.startsWith(prefix)) continue;
|
|
43
|
+
args[key.slice(prefix.length)] = props[key];
|
|
44
|
+
count++;
|
|
45
|
+
}
|
|
46
|
+
if (count === 0) {
|
|
47
|
+
delete props[baseName];
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const keys = Object.keys(args);
|
|
51
|
+
if (keys.length === 1 && /^\d+$/.test(keys[0])) {
|
|
52
|
+
props[baseName] = args[keys[0]];
|
|
53
|
+
} else {
|
|
54
|
+
props[baseName] = args;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Walk every key in `props`, collect the set of applicator base names,
|
|
60
|
+
* and refresh each one. Used on the initial Create patch where the whole
|
|
61
|
+
* prop bag arrives at once.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeAllApplicators(props: Record<string, any>): void {
|
|
64
|
+
const bases = new Set<string>();
|
|
65
|
+
for (const key of Object.keys(props)) {
|
|
66
|
+
const base = parseApplicatorBase(key);
|
|
67
|
+
if (base !== null) bases.add(base);
|
|
68
|
+
}
|
|
69
|
+
for (const base of bases) refreshApplicator(props, base);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strip the engine's DSL prefixes from an action name. The Hypen DSL writes
|
|
74
|
+
* `@router.push` / `@actions.toggleLike`; the engine wants `router.push` /
|
|
75
|
+
* `toggleLike` on the wire. Mirrors the DOM renderer's
|
|
76
|
+
* `extractActionDetails` in `dom/applicators/events.ts`.
|
|
77
|
+
*/
|
|
78
|
+
function stripActionPrefixes(raw: string): string {
|
|
79
|
+
if (!raw.startsWith("@")) return raw;
|
|
80
|
+
let name = raw.substring(1);
|
|
81
|
+
if (name.startsWith("actions.")) name = name.substring(8);
|
|
82
|
+
return name;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve an event applicator value into `(actionName, payload)`.
|
|
87
|
+
*
|
|
88
|
+
* After normalisation, `onClick` is either:
|
|
89
|
+
* - a string: `"@router.push"` (single positional, no extra args)
|
|
90
|
+
* - an object: `{ "0": "@router.push", to: "/notifications" }`
|
|
91
|
+
*
|
|
92
|
+
* Strips the leading `@` and the `actions.` namespace prefix so the wire
|
|
93
|
+
* format matches what the DOM renderer sends — without this, `@router.push`
|
|
94
|
+
* went out verbatim and the engine ignored it because it expects the
|
|
95
|
+
* stripped form (`router.push`).
|
|
96
|
+
*
|
|
97
|
+
* Returns `null` if no dispatchable action name could be found.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveEventAction(
|
|
100
|
+
spec: unknown,
|
|
101
|
+
): { actionName: string; payload: Record<string, any> } | null {
|
|
102
|
+
if (typeof spec === "string") {
|
|
103
|
+
if (!spec.startsWith("@")) return null;
|
|
104
|
+
return { actionName: stripActionPrefixes(spec), payload: {} };
|
|
105
|
+
}
|
|
106
|
+
if (spec && typeof spec === "object") {
|
|
107
|
+
const obj = spec as Record<string, any>;
|
|
108
|
+
const raw = obj["0"];
|
|
109
|
+
if (typeof raw !== "string" || !raw.startsWith("@")) return null;
|
|
110
|
+
const payload: Record<string, any> = {};
|
|
111
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
112
|
+
if (k !== "0") payload[k] = v;
|
|
113
|
+
}
|
|
114
|
+
return { actionName: stripActionPrefixes(raw), payload };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
package/src/canvas/renderer.ts
CHANGED
|
@@ -30,6 +30,11 @@ import { ScrollManager } from "./scroll.js";
|
|
|
30
30
|
import { SelectionManager } from "./selection.js";
|
|
31
31
|
import { setSelectionManager } from "./paint.js";
|
|
32
32
|
import { DirtyRectTracker } from "./dirty.js";
|
|
33
|
+
import {
|
|
34
|
+
normalizeAllApplicators,
|
|
35
|
+
refreshApplicator,
|
|
36
|
+
parseApplicatorBase,
|
|
37
|
+
} from "./props.js";
|
|
33
38
|
|
|
34
39
|
const DEFAULT_OPTIONS: CanvasRendererOptions = {
|
|
35
40
|
devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
|
|
@@ -104,6 +109,29 @@ export class CanvasRenderer implements Renderer {
|
|
|
104
109
|
this.options.enableAccessibility || false
|
|
105
110
|
);
|
|
106
111
|
|
|
112
|
+
// Bridge focus changes from the hit-tester to the HTML input overlay.
|
|
113
|
+
// Without this, `Input`/`Textarea` painted on the canvas but clicking
|
|
114
|
+
// one did nothing — the overlay never got mounted.
|
|
115
|
+
this.eventManager.setFocusChangeHandler((next) => {
|
|
116
|
+
const t = next ? next.type.toLowerCase() : null;
|
|
117
|
+
if (next && (t === "input" || t === "textarea")) {
|
|
118
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
119
|
+
this.inputOverlay.showInput(
|
|
120
|
+
next,
|
|
121
|
+
rect,
|
|
122
|
+
(value) => {
|
|
123
|
+
// Local mirror so the next paint sees the typed text even
|
|
124
|
+
// before the engine echoes it back via SetProp.
|
|
125
|
+
next.props.value = value;
|
|
126
|
+
this.scheduleRedraw();
|
|
127
|
+
},
|
|
128
|
+
this.engine,
|
|
129
|
+
);
|
|
130
|
+
} else {
|
|
131
|
+
this.inputOverlay.hideInput();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
107
135
|
// Listen for redraw requests from event manager
|
|
108
136
|
this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
|
|
109
137
|
|
|
@@ -203,6 +231,14 @@ export class CanvasRenderer implements Renderer {
|
|
|
203
231
|
case "remove":
|
|
204
232
|
this.onRemove(patch.id!);
|
|
205
233
|
break;
|
|
234
|
+
|
|
235
|
+
case "detach":
|
|
236
|
+
this.onDetach(patch.id!);
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case "attach":
|
|
240
|
+
this.onInsert(patch.parentId!, patch.id!, patch.beforeId);
|
|
241
|
+
break;
|
|
206
242
|
}
|
|
207
243
|
}
|
|
208
244
|
|
|
@@ -210,17 +246,33 @@ export class CanvasRenderer implements Renderer {
|
|
|
210
246
|
* Create new virtual node
|
|
211
247
|
*/
|
|
212
248
|
private onCreate(id: string, elementType: string, props: Record<string, any>): void {
|
|
249
|
+
// Engine may send a Map (from WASM) or a plain object. Copy either way so
|
|
250
|
+
// we own the prop bag and can mutate it during applicator normalisation.
|
|
251
|
+
const rawProps: Record<string, any> =
|
|
252
|
+
props instanceof Map ? Object.fromEntries(props) : { ...props };
|
|
253
|
+
normalizeAllApplicators(rawProps);
|
|
254
|
+
|
|
255
|
+
// Engine emits the element type capitalised (`"Button"`, `"Text"`, …);
|
|
256
|
+
// every per-type comparison downstream lowercases it, so do the same
|
|
257
|
+
// for clickable/focusable. Without this, Buttons weren't flagged as
|
|
258
|
+
// clickable (so hover/cursor barely fired) and Inputs/Textareas
|
|
259
|
+
// weren't focusable (caret never landed).
|
|
260
|
+
const lowerType = elementType.toLowerCase();
|
|
213
261
|
const node: VirtualNode = {
|
|
214
262
|
id,
|
|
215
263
|
type: elementType,
|
|
216
|
-
props:
|
|
264
|
+
props: rawProps,
|
|
217
265
|
children: [],
|
|
218
266
|
parent: null,
|
|
219
267
|
visible: true,
|
|
220
|
-
opacity: parseFloat(
|
|
221
|
-
clickable:
|
|
268
|
+
opacity: parseFloat(rawProps.opacity) || 1,
|
|
269
|
+
clickable:
|
|
270
|
+
lowerType === "button" ||
|
|
271
|
+
rawProps.onClick != null ||
|
|
272
|
+
rawProps.onclick != null ||
|
|
273
|
+
rawProps.action != null,
|
|
222
274
|
hoverable: true,
|
|
223
|
-
focusable:
|
|
275
|
+
focusable: lowerType === "input" || lowerType === "textarea" || lowerType === "button",
|
|
224
276
|
focused: false,
|
|
225
277
|
hovered: false,
|
|
226
278
|
};
|
|
@@ -242,15 +294,30 @@ export class CanvasRenderer implements Renderer {
|
|
|
242
294
|
|
|
243
295
|
node.props[name] = value;
|
|
244
296
|
|
|
245
|
-
//
|
|
297
|
+
// If this is an applicator-namespaced key (e.g. `flex.0`, `onClick.to`),
|
|
298
|
+
// rebuild the derived flat/aggregate entry under the base name so layout,
|
|
299
|
+
// paint, and event dispatch see the updated value.
|
|
300
|
+
const base = parseApplicatorBase(name);
|
|
301
|
+
if (base !== null) {
|
|
302
|
+
refreshApplicator(node.props, base);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Update computed properties — check both the direct key and the
|
|
306
|
+
// post-refresh derived key so setProp on `opacity.0` still updates opacity.
|
|
246
307
|
if (name === "visible") {
|
|
247
308
|
node.visible = !!value;
|
|
248
309
|
}
|
|
249
|
-
if (name === "opacity") {
|
|
250
|
-
node.opacity = parseFloat(
|
|
310
|
+
if (name === "opacity" || base === "opacity") {
|
|
311
|
+
node.opacity = parseFloat(node.props.opacity) || 1;
|
|
251
312
|
}
|
|
252
|
-
if (
|
|
253
|
-
|
|
313
|
+
if (
|
|
314
|
+
name === "onClick" || name === "onclick" || name === "action" ||
|
|
315
|
+
base === "onClick" || base === "onclick" || base === "action"
|
|
316
|
+
) {
|
|
317
|
+
node.clickable =
|
|
318
|
+
node.props.onClick != null ||
|
|
319
|
+
node.props.onclick != null ||
|
|
320
|
+
node.props.action != null;
|
|
254
321
|
}
|
|
255
322
|
|
|
256
323
|
// Mark dirty after prop change (new bounds will be captured after layout)
|
|
@@ -275,11 +342,25 @@ export class CanvasRenderer implements Renderer {
|
|
|
275
342
|
|
|
276
343
|
delete node.props[name];
|
|
277
344
|
|
|
345
|
+
const base = parseApplicatorBase(name);
|
|
346
|
+
if (base !== null) {
|
|
347
|
+
refreshApplicator(node.props, base);
|
|
348
|
+
}
|
|
349
|
+
|
|
278
350
|
if (name === "visible") {
|
|
279
351
|
node.visible = true;
|
|
280
352
|
}
|
|
281
|
-
if (name === "opacity") {
|
|
282
|
-
node.opacity = 1;
|
|
353
|
+
if (name === "opacity" || base === "opacity") {
|
|
354
|
+
node.opacity = parseFloat(node.props.opacity) || 1;
|
|
355
|
+
}
|
|
356
|
+
if (
|
|
357
|
+
name === "onClick" || name === "onclick" || name === "action" ||
|
|
358
|
+
base === "onClick" || base === "onclick" || base === "action"
|
|
359
|
+
) {
|
|
360
|
+
node.clickable =
|
|
361
|
+
node.props.onClick != null ||
|
|
362
|
+
node.props.onclick != null ||
|
|
363
|
+
node.props.action != null;
|
|
283
364
|
}
|
|
284
365
|
|
|
285
366
|
if (this.options.enableDirtyRects) {
|
|
@@ -384,6 +465,46 @@ export class CanvasRenderer implements Renderer {
|
|
|
384
465
|
this.onInsert(parentId, id, beforeId);
|
|
385
466
|
}
|
|
386
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Detach a subtree from its parent without destroying it.
|
|
470
|
+
*
|
|
471
|
+
* The VirtualNode and its descendants stay in `this.nodes`, just
|
|
472
|
+
* unlinked from the visible tree (removed from the parent's
|
|
473
|
+
* `children` array and `node.parent = null`). A subsequent
|
|
474
|
+
* `attach` patch reinserts it — scroll offsets, focus state,
|
|
475
|
+
* layout caches, and any other per-node derived state survive.
|
|
476
|
+
*
|
|
477
|
+
* Used by the engine's Router cache to keep off-screen route
|
|
478
|
+
* subtrees alive between navigations so re-entry is instant.
|
|
479
|
+
*/
|
|
480
|
+
private onDetach(id: string): void {
|
|
481
|
+
const node = this.nodes.get(id);
|
|
482
|
+
if (!node) return;
|
|
483
|
+
|
|
484
|
+
// Mark dirty so the next paint doesn't leave stale pixels behind.
|
|
485
|
+
if (this.options.enableDirtyRects) {
|
|
486
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (node.parent) {
|
|
490
|
+
const idx = node.parent.children.indexOf(node);
|
|
491
|
+
if (idx >= 0) {
|
|
492
|
+
node.parent.children.splice(idx, 1);
|
|
493
|
+
if (this.options.enableDirtyRects) {
|
|
494
|
+
this.dirtyTracker.markNodeDirty(node.parent);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
node.parent = null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (this.rootNode === node) {
|
|
501
|
+
this.rootNode = null;
|
|
502
|
+
this.eventManager.setRootNode(null);
|
|
503
|
+
this.scrollManager.setRootNode(null);
|
|
504
|
+
this.selectionManager.setRootNode(null);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
387
508
|
/**
|
|
388
509
|
* Remove node from tree
|
|
389
510
|
*/
|