@hypen-space/web 0.4.980 → 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.
- package/README.md +20 -13
- package/dist/canvas/accessibility.d.ts +118 -11
- package/dist/canvas/accessibility.js +470 -108
- package/dist/canvas/accessibility.js.map +1 -1
- package/dist/canvas/dispatch.d.ts +24 -0
- package/dist/canvas/dispatch.js +62 -0
- package/dist/canvas/dispatch.js.map +1 -0
- package/dist/canvas/editing.d.ts +178 -0
- package/dist/canvas/editing.js +590 -0
- package/dist/canvas/editing.js.map +1 -0
- package/dist/canvas/events.d.ts +28 -23
- package/dist/canvas/events.js +90 -103
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/focus.d.ts +78 -0
- package/dist/canvas/focus.js +182 -0
- package/dist/canvas/focus.js.map +1 -0
- package/dist/canvas/index.d.ts +3 -2
- package/dist/canvas/index.js +3 -2
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.js +68 -98
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +16 -3
- package/dist/canvas/paint.js +449 -110
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +28 -4
- package/dist/canvas/renderer.js +201 -81
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/selection.js +31 -177
- package/dist/canvas/selection.js.map +1 -1
- package/dist/canvas/text-geometry.d.ts +72 -0
- package/dist/canvas/text-geometry.js +244 -0
- package/dist/canvas/text-geometry.js.map +1 -0
- package/dist/canvas/text.js +44 -18
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +10 -1
- package/dist/canvas/utils.d.ts +0 -23
- package/dist/canvas/utils.js +3 -67
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/variants.d.ts +52 -0
- package/dist/canvas/variants.js +156 -0
- package/dist/canvas/variants.js.map +1 -0
- package/dist/dom/a11y-styles.d.ts +20 -0
- package/dist/dom/a11y-styles.js +61 -0
- package/dist/dom/a11y-styles.js.map +1 -0
- package/dist/dom/applicators/aria.d.ts +19 -0
- package/dist/dom/applicators/aria.js +36 -0
- package/dist/dom/applicators/aria.js.map +1 -0
- package/dist/dom/applicators/events.d.ts +7 -0
- package/dist/dom/applicators/events.js +36 -8
- package/dist/dom/applicators/events.js.map +1 -1
- package/dist/dom/applicators/font.d.ts +11 -6
- package/dist/dom/applicators/font.js +16 -27
- package/dist/dom/applicators/font.js.map +1 -1
- package/dist/dom/applicators/index.js +51 -45
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/canvas/index.js +2 -2
- package/dist/dom/canvas/index.js.map +1 -1
- package/dist/dom/components/app.js +2 -2
- package/dist/dom/components/app.js.map +1 -1
- package/dist/dom/components/audio.js +2 -2
- package/dist/dom/components/audio.js.map +1 -1
- package/dist/dom/components/avatar.js +3 -4
- package/dist/dom/components/avatar.js.map +1 -1
- package/dist/dom/components/badge.js +3 -4
- package/dist/dom/components/badge.js.map +1 -1
- package/dist/dom/components/button.js +2 -2
- package/dist/dom/components/button.js.map +1 -1
- package/dist/dom/components/card.js +2 -2
- package/dist/dom/components/card.js.map +1 -1
- package/dist/dom/components/center.js +2 -2
- package/dist/dom/components/center.js.map +1 -1
- package/dist/dom/components/checkbox.js +4 -5
- package/dist/dom/components/checkbox.js.map +1 -1
- package/dist/dom/components/column.js +2 -2
- package/dist/dom/components/column.js.map +1 -1
- package/dist/dom/components/container.js +2 -2
- package/dist/dom/components/container.js.map +1 -1
- package/dist/dom/components/divider.js +2 -2
- package/dist/dom/components/divider.js.map +1 -1
- package/dist/dom/components/grid.js +10 -10
- package/dist/dom/components/grid.js.map +1 -1
- package/dist/dom/components/heading.js +4 -6
- package/dist/dom/components/heading.js.map +1 -1
- package/dist/dom/components/hypenapp.d.ts +25 -1
- package/dist/dom/components/hypenapp.js +215 -247
- package/dist/dom/components/hypenapp.js.map +1 -1
- package/dist/dom/components/icon.js +4 -5
- package/dist/dom/components/icon.js.map +1 -1
- package/dist/dom/components/image.js +2 -2
- package/dist/dom/components/image.js.map +1 -1
- package/dist/dom/components/index.d.ts +2 -2
- package/dist/dom/components/index.js +9 -2
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/components/input.js +2 -2
- package/dist/dom/components/input.js.map +1 -1
- package/dist/dom/components/link.js +2 -2
- package/dist/dom/components/link.js.map +1 -1
- package/dist/dom/components/list.js +2 -2
- package/dist/dom/components/list.js.map +1 -1
- package/dist/dom/components/paragraph.js +3 -4
- package/dist/dom/components/paragraph.js.map +1 -1
- package/dist/dom/components/progressbar.js +3 -3
- package/dist/dom/components/progressbar.js.map +1 -1
- package/dist/dom/components/route.js +2 -2
- package/dist/dom/components/route.js.map +1 -1
- package/dist/dom/components/router.js +2 -2
- package/dist/dom/components/router.js.map +1 -1
- package/dist/dom/components/row.js +10 -10
- package/dist/dom/components/row.js.map +1 -1
- package/dist/dom/components/select.js +3 -4
- package/dist/dom/components/select.js.map +1 -1
- package/dist/dom/components/slider.js +2 -2
- package/dist/dom/components/slider.js.map +1 -1
- package/dist/dom/components/spacer.js +2 -2
- package/dist/dom/components/spacer.js.map +1 -1
- package/dist/dom/components/spinner.js +27 -13
- package/dist/dom/components/spinner.js.map +1 -1
- package/dist/dom/components/stack.js +10 -10
- package/dist/dom/components/stack.js.map +1 -1
- package/dist/dom/components/switch.js +5 -6
- package/dist/dom/components/switch.js.map +1 -1
- package/dist/dom/components/tabs.d.ts +27 -0
- package/dist/dom/components/tabs.js +69 -0
- package/dist/dom/components/tabs.js.map +1 -0
- package/dist/dom/components/text.js +4 -7
- package/dist/dom/components/text.js.map +1 -1
- package/dist/dom/components/textarea.js +2 -2
- package/dist/dom/components/textarea.js.map +1 -1
- package/dist/dom/components/video.js +2 -2
- package/dist/dom/components/video.js.map +1 -1
- package/dist/dom/components/visuallyhidden.d.ts +9 -0
- package/dist/dom/components/visuallyhidden.js +26 -0
- package/dist/dom/components/visuallyhidden.js.map +1 -0
- package/dist/dom/debug.js +6 -8
- package/dist/dom/debug.js.map +1 -1
- package/dist/dom/index.d.ts +0 -1
- package/dist/dom/index.js +0 -1
- package/dist/dom/index.js.map +1 -1
- package/dist/dom/operability.d.ts +100 -0
- package/dist/dom/operability.js +298 -0
- package/dist/dom/operability.js.map +1 -0
- package/dist/dom/renderer.d.ts +98 -5
- package/dist/dom/renderer.js +400 -66
- package/dist/dom/renderer.js.map +1 -1
- package/dist/dom/route-focus.d.ts +42 -0
- package/dist/dom/route-focus.js +88 -0
- package/dist/dom/route-focus.js.map +1 -0
- package/dist/dom/semantics.d.ts +35 -0
- package/dist/dom/semantics.js +184 -0
- package/dist/dom/semantics.js.map +1 -0
- package/dist/variants.d.ts +90 -0
- package/dist/variants.js +206 -0
- package/dist/variants.js.map +1 -0
- package/package.json +2 -2
- package/src/canvas/QUICKSTART.md +16 -11
- package/src/canvas/README.md +63 -34
- package/src/canvas/accessibility.ts +507 -115
- package/src/canvas/dispatch.ts +78 -0
- package/src/canvas/editing.ts +697 -0
- package/src/canvas/events.ts +104 -115
- package/src/canvas/focus.ts +216 -0
- package/src/canvas/index.ts +8 -9
- package/src/canvas/layout.ts +68 -103
- package/src/canvas/paint.ts +509 -109
- package/src/canvas/renderer.ts +239 -99
- package/src/canvas/selection.ts +52 -210
- package/src/canvas/text-geometry.ts +311 -0
- package/src/canvas/text.ts +45 -18
- package/src/canvas/types.ts +28 -1
- package/src/canvas/utils.ts +3 -69
- package/src/canvas/variants.ts +172 -0
- package/src/dom/README.md +8 -7
- package/src/dom/a11y-styles.ts +65 -0
- package/src/dom/applicators/aria.ts +41 -0
- package/src/dom/applicators/events.ts +39 -7
- package/src/dom/applicators/font.ts +16 -29
- package/src/dom/applicators/index.ts +61 -46
- package/src/dom/canvas/index.ts +2 -2
- package/src/dom/components/app.ts +2 -2
- package/src/dom/components/audio.ts +2 -2
- package/src/dom/components/avatar.ts +3 -4
- package/src/dom/components/badge.ts +3 -4
- package/src/dom/components/button.ts +2 -2
- package/src/dom/components/card.ts +2 -2
- package/src/dom/components/center.ts +2 -2
- package/src/dom/components/checkbox.ts +4 -5
- package/src/dom/components/column.ts +2 -2
- package/src/dom/components/container.ts +2 -2
- package/src/dom/components/divider.ts +2 -2
- package/src/dom/components/grid.ts +10 -10
- package/src/dom/components/heading.ts +4 -6
- package/src/dom/components/hypenapp.ts +246 -274
- package/src/dom/components/icon.ts +4 -5
- package/src/dom/components/image.ts +2 -2
- package/src/dom/components/index.ts +10 -3
- package/src/dom/components/input.ts +2 -2
- package/src/dom/components/link.ts +2 -2
- package/src/dom/components/list.ts +2 -2
- package/src/dom/components/paragraph.ts +3 -4
- package/src/dom/components/progressbar.ts +3 -3
- package/src/dom/components/route.ts +2 -2
- package/src/dom/components/router.ts +2 -2
- package/src/dom/components/row.ts +10 -10
- package/src/dom/components/select.ts +3 -4
- package/src/dom/components/slider.ts +2 -2
- package/src/dom/components/spacer.ts +2 -2
- package/src/dom/components/spinner.ts +34 -16
- package/src/dom/components/stack.ts +10 -10
- package/src/dom/components/switch.ts +5 -6
- package/src/dom/components/tabs.ts +76 -0
- package/src/dom/components/text.ts +4 -7
- package/src/dom/components/textarea.ts +2 -2
- package/src/dom/components/video.ts +2 -2
- package/src/dom/components/visuallyhidden.ts +30 -0
- package/src/dom/debug.ts +6 -8
- package/src/dom/index.ts +0 -8
- package/src/dom/operability.ts +312 -0
- package/src/dom/renderer.ts +457 -66
- package/src/dom/route-focus.ts +98 -0
- package/src/dom/semantics.ts +199 -0
- package/src/variants.ts +249 -0
- package/dist/canvas/input.d.ts +0 -76
- package/dist/canvas/input.js +0 -207
- package/dist/canvas/input.js.map +0 -1
- package/dist/client.d.ts +0 -36
- package/dist/client.js +0 -47
- package/dist/client.js.map +0 -1
- package/src/canvas/input.ts +0 -251
- package/src/client.ts +0 -88
package/src/canvas/text.ts
CHANGED
|
@@ -36,9 +36,15 @@ function isPretextAvailable(): boolean {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Text metrics cache
|
|
39
|
+
* Text metrics cache — bounded LRU. Every distinct (text, font, width,
|
|
40
|
+
* clamp) combination measured on the layout/paint hot path lands here, so
|
|
41
|
+
* without a cap a long-lived session (live feeds, ticking clocks, per-width
|
|
42
|
+
* generations from resizes) grows it forever. Map iteration order is
|
|
43
|
+
* insertion order; hits re-insert to keep hot entries at the tail and the
|
|
44
|
+
* oldest entry is evicted past the cap.
|
|
40
45
|
*/
|
|
41
46
|
const textMetricsCache = new Map<string, TextMetrics>();
|
|
47
|
+
const MAX_TEXT_METRICS_CACHE_SIZE = 4096;
|
|
42
48
|
|
|
43
49
|
/**
|
|
44
50
|
* Get cache key for text metrics
|
|
@@ -55,6 +61,13 @@ function wrapTextFallback(ctx: CanvasRenderingContext2D, text: string, maxWidth:
|
|
|
55
61
|
const paragraphs = text.split("\n");
|
|
56
62
|
|
|
57
63
|
for (const paragraph of paragraphs) {
|
|
64
|
+
// An empty paragraph is a blank line — dropping it would collapse
|
|
65
|
+
// consecutive newlines (and a trailing Enter in a Textarea).
|
|
66
|
+
if (paragraph === "") {
|
|
67
|
+
lines.push("");
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
58
71
|
const words = paragraph.split(" ");
|
|
59
72
|
let currentLine = "";
|
|
60
73
|
|
|
@@ -70,9 +83,7 @@ function wrapTextFallback(ctx: CanvasRenderingContext2D, text: string, maxWidth:
|
|
|
70
83
|
}
|
|
71
84
|
}
|
|
72
85
|
|
|
73
|
-
|
|
74
|
-
lines.push(currentLine);
|
|
75
|
-
}
|
|
86
|
+
lines.push(currentLine);
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
return lines.length > 0 ? lines : [""];
|
|
@@ -98,7 +109,12 @@ export function measureText(
|
|
|
98
109
|
): TextMetrics {
|
|
99
110
|
const cacheKey = `${getCacheKey(text, fontStyle, maxWidth)}|${maxLines ?? ""}|${textOverflow ?? ""}`;
|
|
100
111
|
const cached = textMetricsCache.get(cacheKey);
|
|
101
|
-
if (cached)
|
|
112
|
+
if (cached) {
|
|
113
|
+
// Refresh recency so steady-state entries survive eviction.
|
|
114
|
+
textMetricsCache.delete(cacheKey);
|
|
115
|
+
textMetricsCache.set(cacheKey, cached);
|
|
116
|
+
return cached;
|
|
117
|
+
}
|
|
102
118
|
|
|
103
119
|
const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
|
|
104
120
|
const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
|
|
@@ -107,11 +123,25 @@ export function measureText(
|
|
|
107
123
|
let width: number;
|
|
108
124
|
|
|
109
125
|
if (isPretextAvailable()) {
|
|
110
|
-
const prepared = prepareWithSegments(text, font);
|
|
111
126
|
const effectiveMaxWidth = maxWidth || Infinity;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
127
|
+
// Hard line breaks: pretext treats "\n" as ordinary whitespace, so
|
|
128
|
+
// paragraphs are laid out separately (matching the fallback path and
|
|
129
|
+
// what Textarea editing needs). Empty paragraphs stay as blank lines.
|
|
130
|
+
lines = [];
|
|
131
|
+
width = 0;
|
|
132
|
+
for (const paragraph of text.split("\n")) {
|
|
133
|
+
if (paragraph === "") {
|
|
134
|
+
lines.push("");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const prepared = prepareWithSegments(paragraph, font);
|
|
138
|
+
const linesResult = layoutWithLines(prepared, effectiveMaxWidth, lineHeight);
|
|
139
|
+
const paragraphLines = linesResult.lines.map((l) => l.text);
|
|
140
|
+
lines.push(...(paragraphLines.length > 0 ? paragraphLines : [""]));
|
|
141
|
+
for (const l of linesResult.lines) {
|
|
142
|
+
width = Math.max(width, l.width);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
115
145
|
if (lines.length === 0) lines = [""];
|
|
116
146
|
} else {
|
|
117
147
|
ctx.save();
|
|
@@ -170,6 +200,10 @@ export function measureText(
|
|
|
170
200
|
};
|
|
171
201
|
|
|
172
202
|
textMetricsCache.set(cacheKey, result);
|
|
203
|
+
if (textMetricsCache.size > MAX_TEXT_METRICS_CACHE_SIZE) {
|
|
204
|
+
const oldest = textMetricsCache.keys().next().value;
|
|
205
|
+
if (oldest !== undefined) textMetricsCache.delete(oldest);
|
|
206
|
+
}
|
|
173
207
|
return result;
|
|
174
208
|
}
|
|
175
209
|
|
|
@@ -191,13 +225,7 @@ export function renderText(
|
|
|
191
225
|
ctx.save();
|
|
192
226
|
ctx.font = font;
|
|
193
227
|
ctx.fillStyle = style.color;
|
|
194
|
-
|
|
195
|
-
// of its line box. With `top`, canvas draws from the top of the EM square
|
|
196
|
-
// and any extra `line-height − font-size` space lands as padding BELOW the
|
|
197
|
-
// glyph — visible as `+` inside a round FAB drifting toward the top of
|
|
198
|
-
// the circle. CSS centres glyphs within the line box; matching that
|
|
199
|
-
// requires `middle` + half-lineHeight offset.
|
|
200
|
-
ctx.textBaseline = "middle";
|
|
228
|
+
ctx.textBaseline = "top";
|
|
201
229
|
|
|
202
230
|
const metrics = measureText(ctx, text, style, width, maxLines, textOverflow);
|
|
203
231
|
|
|
@@ -212,8 +240,7 @@ export function renderText(
|
|
|
212
240
|
// Render each line
|
|
213
241
|
for (let i = 0; i < metrics.lines.length; i++) {
|
|
214
242
|
const line = metrics.lines[i];
|
|
215
|
-
|
|
216
|
-
const lineY = startY + i * metrics.lineHeight + metrics.lineHeight / 2;
|
|
243
|
+
const lineY = startY + i * metrics.lineHeight;
|
|
217
244
|
|
|
218
245
|
// Calculate X based on text alignment
|
|
219
246
|
let lineX = x;
|
package/src/canvas/types.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* Shared type definitions for the canvas renderer
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { Semantics } from "@hypen-space/core/types";
|
|
8
|
+
|
|
7
9
|
export interface VirtualNode {
|
|
8
10
|
id: string;
|
|
9
11
|
type: string;
|
|
@@ -11,6 +13,13 @@ export interface VirtualNode {
|
|
|
11
13
|
children: VirtualNode[];
|
|
12
14
|
parent: VirtualNode | null;
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Engine-derived accessibility semantics (role, name, hidden, …) carried
|
|
18
|
+
* from the Create patch. Drives the transparent accessibility overlay,
|
|
19
|
+
* since the canvas bitmap itself exposes nothing to assistive technology.
|
|
20
|
+
*/
|
|
21
|
+
semantics?: Semantics;
|
|
22
|
+
|
|
14
23
|
// Computed layout
|
|
15
24
|
layout?: Layout;
|
|
16
25
|
|
|
@@ -27,6 +36,20 @@ export interface VirtualNode {
|
|
|
27
36
|
|
|
28
37
|
// Scroll state (managed by ScrollManager, not serialised)
|
|
29
38
|
scrollState?: ScrollState;
|
|
39
|
+
|
|
40
|
+
// Pointer-pressed (active) state, tracked by the event manager from
|
|
41
|
+
// mousedown/mouseup so `:active` paint variants can resolve.
|
|
42
|
+
pressed?: boolean;
|
|
43
|
+
|
|
44
|
+
// --- Variant resolution bookkeeping (managed by applyVariants) ---
|
|
45
|
+
// Set of applicator base names that have at least one `@bp`/`:state` variant
|
|
46
|
+
// key on this node. Computed lazily; null means "not yet scanned", an empty
|
|
47
|
+
// set means "scanned, no variants".
|
|
48
|
+
variantBases?: Set<string> | null;
|
|
49
|
+
// Snapshot of the node's original (variant-free) base values, captured the
|
|
50
|
+
// first time a variant override is applied so each frame resolves from the
|
|
51
|
+
// un-overridden base instead of compounding overrides.
|
|
52
|
+
variantOriginals?: Record<string, unknown>;
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
export interface Layout {
|
|
@@ -98,9 +121,13 @@ export interface CanvasRendererOptions {
|
|
|
98
121
|
backgroundColor?: string;
|
|
99
122
|
|
|
100
123
|
// Features
|
|
124
|
+
//
|
|
125
|
+
// enableAccessibility also gates text-input editing and keyboard focus:
|
|
126
|
+
// the accessibility mirror (a transparent positioned overlay above the
|
|
127
|
+
// canvas) is the renderer's focus system, and Input/Textarea edit
|
|
128
|
+
// sessions start from mirror focus.
|
|
101
129
|
enableAccessibility?: boolean;
|
|
102
130
|
enableHitTesting?: boolean;
|
|
103
|
-
enableInputOverlay?: boolean;
|
|
104
131
|
|
|
105
132
|
// Performance
|
|
106
133
|
enableDirtyRects?: boolean;
|
package/src/canvas/utils.ts
CHANGED
|
@@ -12,42 +12,6 @@ import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
|
|
|
12
12
|
// and treating `3.5rem` as 3.5px, collapsing whole layouts to 1/16 size.
|
|
13
13
|
const ROOT_FONT_PX = 16;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* Active layout viewport — used to resolve `vw` / `vh` correctly during a
|
|
17
|
-
* layout pass. Set by `computeLayout` (layout.ts) at entry and cleared on
|
|
18
|
-
* exit. Layout traversal is synchronous, so a single module-level slot is
|
|
19
|
-
* sufficient and avoids threading a `viewport` parameter through every
|
|
20
|
-
* `cssLengthToPx` call site (there are dozens).
|
|
21
|
-
*
|
|
22
|
-
* Falls back to `window.innerWidth` / `window.innerHeight` outside a layout
|
|
23
|
-
* pass (e.g. for direct callers that resolve sizes ahead of layout). When
|
|
24
|
-
* neither is available — e.g. headless tests — `vw`/`vh` degrade to the
|
|
25
|
-
* pre-fix unitless-pixel behavior.
|
|
26
|
-
*/
|
|
27
|
-
let currentViewport: { width: number; height: number } | null = null;
|
|
28
|
-
|
|
29
|
-
export function setCurrentViewport(viewport: { width: number; height: number } | null): void {
|
|
30
|
-
currentViewport = viewport;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function getCurrentViewport(): { width: number; height: number } | null {
|
|
34
|
-
return currentViewport;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveViewportPx(unit: "vw" | "vh", n: number): number {
|
|
38
|
-
if (currentViewport) {
|
|
39
|
-
const dim = unit === "vw" ? currentViewport.width : currentViewport.height;
|
|
40
|
-
return (n / 100) * dim;
|
|
41
|
-
}
|
|
42
|
-
if (typeof window !== "undefined") {
|
|
43
|
-
const dim = unit === "vw" ? window.innerWidth : window.innerHeight;
|
|
44
|
-
if (Number.isFinite(dim) && dim > 0) return (n / 100) * dim;
|
|
45
|
-
}
|
|
46
|
-
// Last resort: treat as unitless px so the layout still gets a numeric
|
|
47
|
-
// value (matches pre-fix behavior in the no-viewport case).
|
|
48
|
-
return n;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
15
|
/**
|
|
52
16
|
* Parse a CSS length string (or number) into pixels.
|
|
53
17
|
* Returns `null` for `"auto"` or unparseable input; returns `null` for `%`
|
|
@@ -69,8 +33,10 @@ export function cssLengthToPx(value: any): number | null {
|
|
|
69
33
|
case "px": return n;
|
|
70
34
|
case "rem":
|
|
71
35
|
case "em": return n * ROOT_FONT_PX;
|
|
36
|
+
// We don't have a viewport reference here; treat vw/vh as unitless px
|
|
37
|
+
// fallback so the layout gets _some_ numeric value rather than zero.
|
|
72
38
|
case "vw":
|
|
73
|
-
case "vh": return
|
|
39
|
+
case "vh": return n;
|
|
74
40
|
// Typographic point — 1/72 inch at CSS reference density (96dpi).
|
|
75
41
|
case "pt": return n * (96 / 72);
|
|
76
42
|
// Density-independent (dp) and scale-independent (sp) logical pixels
|
|
@@ -86,38 +52,6 @@ export function cssLengthToPx(value: any): number | null {
|
|
|
86
52
|
}
|
|
87
53
|
}
|
|
88
54
|
|
|
89
|
-
/**
|
|
90
|
-
* Resolve a CSS `line-height` value to a pixel value.
|
|
91
|
-
*
|
|
92
|
-
* Unitless numbers (e.g. `1`, `1.5`, `"1.25"`) are CSS multipliers of the
|
|
93
|
-
* font-size — `line-height: 1` (Tailwind's `leading-none`) means line box =
|
|
94
|
-
* 1 × font-size, NOT 1 pixel. Without this branch, `cssLengthToPx("1")`
|
|
95
|
-
* returned literally `1`, and a Column of `leading-none` Texts collapsed
|
|
96
|
-
* each line to 1px tall — emoji + label in a bottom-tab button rendered at
|
|
97
|
-
* the same y and overlapped.
|
|
98
|
-
*
|
|
99
|
-
* Anything with an explicit unit (`16px`, `1.5em`, `1rem`, `120%` is
|
|
100
|
-
* rejected → null since Taffy/measure expects px) falls through to
|
|
101
|
-
* `cssLengthToPx`.
|
|
102
|
-
*/
|
|
103
|
-
export function resolveLineHeight(value: any, fontSize: number): number | null {
|
|
104
|
-
if (value === undefined || value === null) return null;
|
|
105
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
106
|
-
// Unitless number: multiplier of font-size (matches CSS spec).
|
|
107
|
-
return value * fontSize;
|
|
108
|
-
}
|
|
109
|
-
if (typeof value === "string") {
|
|
110
|
-
const s = value.trim();
|
|
111
|
-
if (s === "" || s === "auto" || s === "normal") return null;
|
|
112
|
-
// Pure numeric string with no unit → unitless multiplier.
|
|
113
|
-
if (/^-?\d*\.?\d+$/.test(s)) {
|
|
114
|
-
const n = parseFloat(s);
|
|
115
|
-
return Number.isFinite(n) ? n * fontSize : null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return cssLengthToPx(value);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
55
|
/**
|
|
122
56
|
* Parse a CSS length string for Taffy's Dimension type. Keeps `%` values as
|
|
123
57
|
* the tagged percentage string, turns `"auto"`/empty/invalid into `"auto"`,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas variant application.
|
|
3
|
+
*
|
|
4
|
+
* The Canvas renderer reads winning values directly off `node.props.<base>`
|
|
5
|
+
* (e.g. `props.padding`, `props.backgroundColor`) in both layout.ts and
|
|
6
|
+
* paint.ts. To support responsive breakpoints and interaction-state variants
|
|
7
|
+
* WITHOUT rewriting every call site, we resolve variant keys once per frame
|
|
8
|
+
* (just before layout) and stamp the winning value onto the base key.
|
|
9
|
+
*
|
|
10
|
+
* Resolution itself is delegated to the shared, renderer-agnostic helper in
|
|
11
|
+
* `../variants.ts`, so breakpoint pixels, state names, and precedence stay
|
|
12
|
+
* byte-compatible with the DOM renderer.
|
|
13
|
+
*
|
|
14
|
+
* Per-node bookkeeping (`variantBases`, `variantOriginals`) lives on the
|
|
15
|
+
* VirtualNode so each frame:
|
|
16
|
+
* 1. restores the original (variant-free) base value, then
|
|
17
|
+
* 2. re-applies the current winner for the present width + active states.
|
|
18
|
+
* This makes the pass idempotent and reversible — when a hover ends or the
|
|
19
|
+
* canvas shrinks below a breakpoint, the base value comes back automatically.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { VirtualNode } from "./types.js";
|
|
23
|
+
import {
|
|
24
|
+
resolveVariantProps,
|
|
25
|
+
parseVariantKey,
|
|
26
|
+
type ActiveStates,
|
|
27
|
+
} from "../variants.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Lazily compute (and cache) the set of applicator base names on this node that
|
|
31
|
+
* carry at least one variant key. Returns the cached set on subsequent calls.
|
|
32
|
+
*
|
|
33
|
+
* The cache is invalidated by `invalidateVariantCache`, called from the
|
|
34
|
+
* renderer whenever a SetProp/RemoveProp changes the node's prop keys.
|
|
35
|
+
*/
|
|
36
|
+
function getVariantBases(node: VirtualNode): Set<string> {
|
|
37
|
+
if (node.variantBases != null) return node.variantBases;
|
|
38
|
+
const bases = new Set<string>();
|
|
39
|
+
for (const key of Object.keys(node.props)) {
|
|
40
|
+
const parsed = parseVariantKey(key);
|
|
41
|
+
if (parsed.breakpoint || parsed.state) bases.add(parsed.base);
|
|
42
|
+
}
|
|
43
|
+
node.variantBases = bases;
|
|
44
|
+
return bases;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Drop the cached variant state so it is recomputed on the next frame.
|
|
49
|
+
* Call after any prop mutation that may add/remove variant keys.
|
|
50
|
+
*
|
|
51
|
+
* Also drops the `variantOriginals` snapshot: a SetProp can change the *base*
|
|
52
|
+
* value of a base that carries a variant (e.g. `padding.0` updated while
|
|
53
|
+
* `padding@md.0` exists). The snapshot was taken from the previous frame's
|
|
54
|
+
* props, so restoring it would clobber the engine's new base value with the
|
|
55
|
+
* stale one whenever the variant isn't currently active. Clearing it forces the
|
|
56
|
+
* next frame to re-snapshot from the current (post-SetProp) props.
|
|
57
|
+
*/
|
|
58
|
+
export function invalidateVariantCache(node: VirtualNode): void {
|
|
59
|
+
node.variantBases = null;
|
|
60
|
+
node.variantOriginals = undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Derive the active interaction states for a node from its tracked flags and
|
|
65
|
+
* props, mirroring the DOM/CSS pseudo-class semantics:
|
|
66
|
+
* - hover : pointer is over the node (or a child resolved to it)
|
|
67
|
+
* - focus : node holds canvas focus
|
|
68
|
+
* - active : pointer is currently pressed on the node
|
|
69
|
+
* - disabled : node opts out via `disabled`/`enabled:false`
|
|
70
|
+
*/
|
|
71
|
+
function activeStatesFor(node: VirtualNode): ActiveStates {
|
|
72
|
+
const disabled =
|
|
73
|
+
node.props.disabled === true ||
|
|
74
|
+
node.props.disabled === "true" ||
|
|
75
|
+
node.props.enabled === false ||
|
|
76
|
+
node.props.enabled === "false";
|
|
77
|
+
return {
|
|
78
|
+
hover: !!node.hovered,
|
|
79
|
+
focus: !!node.focused,
|
|
80
|
+
active: !!node.pressed,
|
|
81
|
+
disabled,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Resolve and apply variant overrides for a single node, in place.
|
|
87
|
+
*
|
|
88
|
+
* No-op for nodes without any variant keys (the common case), so the per-frame
|
|
89
|
+
* cost on a plain tree is one cached set lookup per node.
|
|
90
|
+
*
|
|
91
|
+
* Returns true when any base value differs from the previous frame's
|
|
92
|
+
* effective value — the renderer uses this to decide whether layout must
|
|
93
|
+
* re-run (variant winners can change spacing/size, not just paint props).
|
|
94
|
+
*/
|
|
95
|
+
function applyVariantsToNode(node: VirtualNode, width: number): boolean {
|
|
96
|
+
const bases = getVariantBases(node);
|
|
97
|
+
if (bases.size === 0) return false;
|
|
98
|
+
|
|
99
|
+
// Snapshot the previous frame's effective values for change detection.
|
|
100
|
+
const previous: Record<string, unknown> = {};
|
|
101
|
+
for (const base of bases) {
|
|
102
|
+
previous[base] = node.props[base];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Restore originals from the previous frame so we resolve from a clean base.
|
|
106
|
+
if (node.variantOriginals) {
|
|
107
|
+
for (const base of bases) {
|
|
108
|
+
const orig = node.variantOriginals[base];
|
|
109
|
+
if (orig === undefined) {
|
|
110
|
+
// The base had no plain value originally — remove any override we wrote.
|
|
111
|
+
delete node.props[base];
|
|
112
|
+
} else {
|
|
113
|
+
node.props[base] = orig;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const resolved = resolveVariantProps(
|
|
119
|
+
node.props as Record<string, unknown>,
|
|
120
|
+
width,
|
|
121
|
+
activeStatesFor(node),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Capture originals (once we know which bases are involved) and apply winners.
|
|
125
|
+
const originals: Record<string, unknown> = {};
|
|
126
|
+
for (const base of bases) {
|
|
127
|
+
originals[base] = node.props[base];
|
|
128
|
+
}
|
|
129
|
+
node.variantOriginals = originals;
|
|
130
|
+
|
|
131
|
+
for (const base of bases) {
|
|
132
|
+
if (base in resolved) {
|
|
133
|
+
node.props[base] = resolved[base];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const base of bases) {
|
|
138
|
+
if (node.props[base] !== previous[base]) return true;
|
|
139
|
+
}
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Walk the tree from `root` and apply variant overrides to every node using
|
|
145
|
+
* `width` (the canvas/content width) as the viewport for breakpoint matching.
|
|
146
|
+
* Called once per frame before layout.
|
|
147
|
+
*
|
|
148
|
+
* Returns true when any node's effective props changed since the previous
|
|
149
|
+
* frame (breakpoint crossed, hover/focus/active/disabled state flipped).
|
|
150
|
+
*/
|
|
151
|
+
export function applyVariants(root: VirtualNode, width: number): boolean {
|
|
152
|
+
let changed = applyVariantsToNode(root, width);
|
|
153
|
+
for (const child of root.children) {
|
|
154
|
+
if (applyVariants(child, width)) changed = true;
|
|
155
|
+
}
|
|
156
|
+
return changed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Re-derive a node's cached computed fields (`visible`, `opacity`) from its
|
|
161
|
+
* current `props`. These are read on the hot paint/hit-test path, so they are
|
|
162
|
+
* cached on the node — but {@link applyVariants} can rewrite the underlying
|
|
163
|
+
* props (`visible@md`, `opacity:disabled`) after the create/setProp caches were
|
|
164
|
+
* set, so this must run after every variant pass or the cache goes stale.
|
|
165
|
+
* Single source of truth so the create / setProp / post-variant sites can't
|
|
166
|
+
* drift apart.
|
|
167
|
+
*/
|
|
168
|
+
export function deriveNodeComputed(node: VirtualNode): void {
|
|
169
|
+
const o = parseFloat(node.props.opacity as string);
|
|
170
|
+
node.opacity = Number.isFinite(o) ? o : 1;
|
|
171
|
+
node.visible = node.props.visible === undefined ? true : !!node.props.visible;
|
|
172
|
+
}
|
package/src/dom/README.md
CHANGED
|
@@ -37,17 +37,18 @@ src/dom/
|
|
|
37
37
|
|
|
38
38
|
### Browser Import
|
|
39
39
|
|
|
40
|
-
The recommended one-call wiring is `createHypenClient`. The bare `DOMRenderer` constructor is still exported for advanced cases (custom subscription, multi-listener fan-out, etc.).
|
|
41
|
-
|
|
42
40
|
```typescript
|
|
43
41
|
import { app, HypenModuleInstance } from "@hypen-space/core";
|
|
44
42
|
import { Engine } from "@hypen-space/web-engine";
|
|
45
|
-
import {
|
|
43
|
+
import { DOMRenderer } from "@hypen-space/web";
|
|
46
44
|
|
|
47
45
|
const engine = new Engine();
|
|
48
46
|
await engine.init();
|
|
49
47
|
|
|
50
|
-
|
|
48
|
+
const renderer = new DOMRenderer(document.body, engine);
|
|
49
|
+
engine.setRenderCallback((patches) => {
|
|
50
|
+
renderer.applyPatches(patches);
|
|
51
|
+
});
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
### Server Import
|
|
@@ -78,7 +79,7 @@ All components are registered by default:
|
|
|
78
79
|
### Custom Components
|
|
79
80
|
|
|
80
81
|
```typescript
|
|
81
|
-
const
|
|
82
|
+
const renderer = new DOMRenderer(document.body, engine);
|
|
82
83
|
const registry = renderer.getComponentRegistry();
|
|
83
84
|
|
|
84
85
|
registry.register("mycomponent", {
|
|
@@ -140,7 +141,7 @@ Style applicators are registered by default:
|
|
|
140
141
|
### Custom Applicators
|
|
141
142
|
|
|
142
143
|
```typescript
|
|
143
|
-
const
|
|
144
|
+
const renderer = new DOMRenderer(document.body, engine);
|
|
144
145
|
const applicators = renderer.getApplicatorRegistry();
|
|
145
146
|
|
|
146
147
|
applicators.register("shadow", (el, value) => {
|
|
@@ -182,7 +183,7 @@ Canvas components have special applicators for drawing:
|
|
|
182
183
|
|
|
183
184
|
```typescript
|
|
184
185
|
// Register canvas applicators
|
|
185
|
-
const
|
|
186
|
+
const renderer = new DOMRenderer(document.body, engine);
|
|
186
187
|
const applicators = renderer.getApplicatorRegistry();
|
|
187
188
|
|
|
188
189
|
// Canvas-specific applicators
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global accessibility stylesheet.
|
|
3
|
+
*
|
|
4
|
+
* Injects a single <style> element (idempotent, guarded by id) that:
|
|
5
|
+
* - honours `prefers-reduced-motion: reduce` by disabling transitions,
|
|
6
|
+
* animations and smooth scrolling on Hypen-rendered nodes;
|
|
7
|
+
* - renders a visible `:focus-visible` outline so keyboard users can see
|
|
8
|
+
* which element is focused;
|
|
9
|
+
* - keeps that focus indicator visible under `forced-colors: active`
|
|
10
|
+
* (Windows High Contrast), which strips box-shadow but honours outline; and
|
|
11
|
+
* - strengthens the outline under `prefers-contrast: more`.
|
|
12
|
+
*
|
|
13
|
+
* Scoped to `[data-hypen-id]` so it only affects Hypen-rendered elements and
|
|
14
|
+
* never the host page.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const STYLE_ID = "hypen-a11y-styles";
|
|
18
|
+
|
|
19
|
+
const A11Y_CSS = `
|
|
20
|
+
@media (prefers-reduced-motion: reduce) {
|
|
21
|
+
[data-hypen-id] {
|
|
22
|
+
transition: none !important;
|
|
23
|
+
animation: none !important;
|
|
24
|
+
scroll-behavior: auto !important;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
[data-hypen-id]:focus-visible {
|
|
28
|
+
outline: 2px solid #1a73e8;
|
|
29
|
+
outline-offset: 2px;
|
|
30
|
+
}
|
|
31
|
+
@media (forced-colors: active) {
|
|
32
|
+
/* Forced-colors mode removes box-shadow and author colors but honours
|
|
33
|
+
outline: pin the focus indicator to an outline in the system Highlight
|
|
34
|
+
color, and drop any box-shadow-based indicator a component style set. */
|
|
35
|
+
[data-hypen-id]:focus-visible {
|
|
36
|
+
outline: 2px solid Highlight;
|
|
37
|
+
box-shadow: none;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
@media (prefers-contrast: more) {
|
|
41
|
+
/* A thicker outline for users who asked for higher contrast. */
|
|
42
|
+
[data-hypen-id]:focus-visible {
|
|
43
|
+
outline-width: 3px;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Inject the accessibility stylesheet once. Safe to call repeatedly and in
|
|
50
|
+
* non-DOM environments (server/tests without `document`), where it no-ops.
|
|
51
|
+
*/
|
|
52
|
+
export function ensureA11yStyles(): void {
|
|
53
|
+
if (typeof document === "undefined" || !document.head) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof document.getElementById === "function" && document.getElementById(STYLE_ID)) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const style = document.createElement("style");
|
|
62
|
+
style.id = STYLE_ID;
|
|
63
|
+
style.textContent = A11Y_CSS;
|
|
64
|
+
document.head.appendChild(style);
|
|
65
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ARIA escape hatch applicator
|
|
3
|
+
*
|
|
4
|
+
* `.aria(key, value)` sets an arbitrary `aria-<key>` attribute on the element,
|
|
5
|
+
* e.g. `.aria("expanded", "true")` -> `aria-expanded="true"`.
|
|
6
|
+
*
|
|
7
|
+
* WEB-ONLY / NON-PORTABLE: this is a raw DOM escape hatch. It bypasses the
|
|
8
|
+
* engine-derived typed `Semantics` block, so the value does NOT reach the
|
|
9
|
+
* Canvas, iOS or Android renderers. Prefer the portable accessibility
|
|
10
|
+
* applicators (e.g. `.role()`, `.label()`) when one exists; reach for `.aria`
|
|
11
|
+
* only for DOM-specific ARIA attributes the typed semantics do not cover.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ApplicatorHandler } from "./types.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The grouped args arrive as `{ "0": key, "1": value }` for `.aria(key, value)`.
|
|
18
|
+
* A single positional arg (`.aria("busy")`) arrives as the bare key string.
|
|
19
|
+
*/
|
|
20
|
+
export const ariaHandler: ApplicatorHandler = (el, value) => {
|
|
21
|
+
let key: unknown;
|
|
22
|
+
let attrValue: unknown = "";
|
|
23
|
+
|
|
24
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
25
|
+
const args = value as Record<string, unknown>;
|
|
26
|
+
key = args["0"];
|
|
27
|
+
attrValue = "1" in args ? args["1"] : "";
|
|
28
|
+
} else {
|
|
29
|
+
key = value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (key === undefined || key === null || key === "") {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
el.setAttribute("aria-" + String(key), String(attrValue));
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const ariaHandlers: Record<string, ApplicatorHandler> = {
|
|
40
|
+
aria: ariaHandler,
|
|
41
|
+
};
|