@hypen-space/web 0.4.46 → 0.4.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/canvas/dirty.d.ts +40 -0
- package/dist/canvas/dirty.js +100 -0
- package/dist/canvas/dirty.js.map +1 -0
- package/dist/canvas/events.d.ts +5 -0
- package/dist/canvas/events.js +30 -3
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/index.d.ts +5 -1
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.d.ts +12 -2
- package/dist/canvas/layout.js +593 -63
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +2 -0
- package/dist/canvas/paint.js +88 -11
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +16 -0
- package/dist/canvas/renderer.js +154 -15
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +82 -0
- package/dist/canvas/scroll.js +639 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/selection.d.ts +63 -0
- package/dist/canvas/selection.js +466 -0
- package/dist/canvas/selection.js.map +1 -0
- package/dist/canvas/text.d.ts +6 -3
- package/dist/canvas/text.js +82 -43
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +18 -0
- package/dist/canvas/utils.d.ts +8 -2
- package/dist/canvas/utils.js +42 -16
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/virtualize.d.ts +25 -0
- package/dist/canvas/virtualize.js +65 -0
- package/dist/canvas/virtualize.js.map +1 -0
- package/dist/dom/applicators/index.js +0 -1
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/applicators/margin.js +58 -17
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +42 -17
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/components/icon.d.ts +9 -0
- package/dist/dom/components/icon.js +67 -0
- package/dist/dom/components/icon.js.map +1 -0
- package/dist/dom/components/index.js +2 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/renderer.d.ts +19 -5
- package/dist/dom/renderer.js +147 -17
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +4 -2
- package/src/canvas/QUICKSTART.md +5 -5
- package/src/canvas/dirty.ts +116 -0
- package/src/canvas/events.ts +34 -3
- package/src/canvas/index.ts +5 -0
- package/src/canvas/layout.ts +653 -100
- package/src/canvas/paint.ts +109 -11
- package/src/canvas/renderer.ts +189 -16
- package/src/canvas/scroll.ts +729 -0
- package/src/canvas/selection.ts +560 -0
- package/src/canvas/text.ts +97 -54
- package/src/canvas/types.ts +26 -0
- package/src/canvas/utils.ts +47 -17
- package/src/canvas/virtualize.ts +81 -0
- package/src/dom/applicators/index.ts +1 -1
- package/src/dom/applicators/margin.ts +57 -11
- package/src/dom/applicators/padding.ts +45 -11
- package/src/dom/components/icon.ts +84 -0
- package/src/dom/components/index.ts +2 -0
- package/src/dom/renderer.ts +155 -17
package/src/canvas/text.ts
CHANGED
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Text Rendering System
|
|
3
3
|
*
|
|
4
|
-
* Text measurement, wrapping, and rendering
|
|
4
|
+
* Text measurement, wrapping, and rendering using pretext for accurate
|
|
5
|
+
* multilingual line breaking (CJK, bidi, emoji, etc.). Falls back to
|
|
6
|
+
* basic word-splitting in environments without OffscreenCanvas (e.g. tests).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { FontStyle, TextMetrics, TextStyle } from "./types.js";
|
|
8
10
|
import { createFontString } from "./utils.js";
|
|
11
|
+
import {
|
|
12
|
+
prepareWithSegments,
|
|
13
|
+
layoutWithLines,
|
|
14
|
+
clearCache as pretextClearCache,
|
|
15
|
+
} from "@chenglou/pretext";
|
|
9
16
|
import { frameworkLoggers } from "@hypen-space/core/logger";
|
|
10
17
|
|
|
11
18
|
const log = frameworkLoggers.canvas;
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Whether pretext is available (requires a working canvas with measureText)
|
|
22
|
+
*/
|
|
23
|
+
let pretextAvailable: boolean | null = null;
|
|
24
|
+
|
|
25
|
+
function isPretextAvailable(): boolean {
|
|
26
|
+
if (pretextAvailable !== null) return pretextAvailable;
|
|
27
|
+
try {
|
|
28
|
+
// Probe by running a minimal prepare — throws if neither OffscreenCanvas
|
|
29
|
+
// nor a real DOM canvas is available (e.g. jsdom in tests).
|
|
30
|
+
prepareWithSegments("x", "16px sans-serif");
|
|
31
|
+
pretextAvailable = true;
|
|
32
|
+
} catch {
|
|
33
|
+
pretextAvailable = false;
|
|
34
|
+
}
|
|
35
|
+
return pretextAvailable;
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
/**
|
|
14
39
|
* Text metrics cache
|
|
15
40
|
*/
|
|
@@ -23,59 +48,9 @@ function getCacheKey(text: string, fontStyle: FontStyle, maxWidth?: number): str
|
|
|
23
48
|
}
|
|
24
49
|
|
|
25
50
|
/**
|
|
26
|
-
*
|
|
27
|
-
*/
|
|
28
|
-
export function measureText(
|
|
29
|
-
ctx: CanvasRenderingContext2D,
|
|
30
|
-
text: string,
|
|
31
|
-
fontStyle: FontStyle,
|
|
32
|
-
maxWidth?: number
|
|
33
|
-
): TextMetrics {
|
|
34
|
-
const cacheKey = getCacheKey(text, fontStyle, maxWidth);
|
|
35
|
-
const cached = textMetricsCache.get(cacheKey);
|
|
36
|
-
if (cached) return cached;
|
|
37
|
-
|
|
38
|
-
const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
|
|
39
|
-
ctx.save();
|
|
40
|
-
ctx.font = font;
|
|
41
|
-
|
|
42
|
-
const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
|
|
43
|
-
|
|
44
|
-
// No wrapping needed
|
|
45
|
-
if (!maxWidth) {
|
|
46
|
-
const metrics = ctx.measureText(text);
|
|
47
|
-
const result: TextMetrics = {
|
|
48
|
-
width: metrics.width,
|
|
49
|
-
height: lineHeight,
|
|
50
|
-
lines: [text],
|
|
51
|
-
lineHeight,
|
|
52
|
-
};
|
|
53
|
-
ctx.restore();
|
|
54
|
-
textMetricsCache.set(cacheKey, result);
|
|
55
|
-
return result;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Wrap text
|
|
59
|
-
const lines = wrapText(ctx, text, maxWidth);
|
|
60
|
-
const width = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
61
|
-
const height = lines.length * lineHeight;
|
|
62
|
-
|
|
63
|
-
const result: TextMetrics = {
|
|
64
|
-
width,
|
|
65
|
-
height,
|
|
66
|
-
lines,
|
|
67
|
-
lineHeight,
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
ctx.restore();
|
|
71
|
-
textMetricsCache.set(cacheKey, result);
|
|
72
|
-
return result;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Wrap text to fit within maxWidth
|
|
51
|
+
* Fallback word wrap for environments without pretext support
|
|
77
52
|
*/
|
|
78
|
-
function
|
|
53
|
+
function wrapTextFallback(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
|
79
54
|
const lines: string[] = [];
|
|
80
55
|
const paragraphs = text.split("\n");
|
|
81
56
|
|
|
@@ -103,6 +78,73 @@ function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number)
|
|
|
103
78
|
return lines.length > 0 ? lines : [""];
|
|
104
79
|
}
|
|
105
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Measure text dimensions. Uses pretext for accurate multilingual line
|
|
83
|
+
* breaking when available, falls back to basic word-splitting otherwise.
|
|
84
|
+
*/
|
|
85
|
+
export function measureText(
|
|
86
|
+
ctx: CanvasRenderingContext2D,
|
|
87
|
+
text: string,
|
|
88
|
+
fontStyle: FontStyle,
|
|
89
|
+
maxWidth?: number
|
|
90
|
+
): TextMetrics {
|
|
91
|
+
const cacheKey = getCacheKey(text, fontStyle, maxWidth);
|
|
92
|
+
const cached = textMetricsCache.get(cacheKey);
|
|
93
|
+
if (cached) return cached;
|
|
94
|
+
|
|
95
|
+
const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
|
|
96
|
+
const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
|
|
97
|
+
|
|
98
|
+
let result: TextMetrics;
|
|
99
|
+
|
|
100
|
+
if (isPretextAvailable()) {
|
|
101
|
+
// pretext handles text shaping, segmentation, and line breaking
|
|
102
|
+
const prepared = prepareWithSegments(text, font);
|
|
103
|
+
const effectiveMaxWidth = maxWidth || Infinity;
|
|
104
|
+
const linesResult = layoutWithLines(prepared, effectiveMaxWidth, lineHeight);
|
|
105
|
+
|
|
106
|
+
const lines = linesResult.lines.map((l) => l.text);
|
|
107
|
+
const width = lines.length > 0
|
|
108
|
+
? Math.max(...linesResult.lines.map((l) => l.width))
|
|
109
|
+
: 0;
|
|
110
|
+
|
|
111
|
+
result = {
|
|
112
|
+
width,
|
|
113
|
+
height: linesResult.height || lineHeight,
|
|
114
|
+
lines: lines.length > 0 ? lines : [""],
|
|
115
|
+
lineHeight,
|
|
116
|
+
};
|
|
117
|
+
} else {
|
|
118
|
+
// Fallback: use ctx.measureText with basic word splitting
|
|
119
|
+
ctx.save();
|
|
120
|
+
ctx.font = font;
|
|
121
|
+
|
|
122
|
+
if (!maxWidth) {
|
|
123
|
+
const metrics = ctx.measureText(text);
|
|
124
|
+
result = {
|
|
125
|
+
width: metrics.width,
|
|
126
|
+
height: lineHeight,
|
|
127
|
+
lines: [text],
|
|
128
|
+
lineHeight,
|
|
129
|
+
};
|
|
130
|
+
} else {
|
|
131
|
+
const lines = wrapTextFallback(ctx, text, maxWidth);
|
|
132
|
+
const width = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
133
|
+
result = {
|
|
134
|
+
width,
|
|
135
|
+
height: lines.length * lineHeight,
|
|
136
|
+
lines,
|
|
137
|
+
lineHeight,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
ctx.restore();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
textMetricsCache.set(cacheKey, result);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
106
148
|
/**
|
|
107
149
|
* Render text with style
|
|
108
150
|
*/
|
|
@@ -153,10 +195,11 @@ export function renderText(
|
|
|
153
195
|
}
|
|
154
196
|
|
|
155
197
|
/**
|
|
156
|
-
* Clear text metrics cache
|
|
198
|
+
* Clear text metrics cache (both local and pretext internal caches)
|
|
157
199
|
*/
|
|
158
200
|
export function clearTextCache(): void {
|
|
159
201
|
textMetricsCache.clear();
|
|
202
|
+
pretextClearCache();
|
|
160
203
|
}
|
|
161
204
|
|
|
162
205
|
/**
|
package/src/canvas/types.ts
CHANGED
|
@@ -24,6 +24,9 @@ export interface VirtualNode {
|
|
|
24
24
|
focusable: boolean;
|
|
25
25
|
focused: boolean;
|
|
26
26
|
hovered: boolean;
|
|
27
|
+
|
|
28
|
+
// Scroll state (managed by ScrollManager, not serialised)
|
|
29
|
+
scrollState?: ScrollState;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
export interface Layout {
|
|
@@ -121,6 +124,29 @@ export interface LayoutFunction {
|
|
|
121
124
|
};
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
export interface ScrollState {
|
|
128
|
+
/** Current scroll offset (pixels from origin) */
|
|
129
|
+
scrollX: number;
|
|
130
|
+
scrollY: number;
|
|
131
|
+
|
|
132
|
+
/** Scroll velocity for momentum (px/ms) */
|
|
133
|
+
velocityX: number;
|
|
134
|
+
velocityY: number;
|
|
135
|
+
|
|
136
|
+
/** Total scrollable content size (set during layout) */
|
|
137
|
+
scrollWidth: number;
|
|
138
|
+
scrollHeight: number;
|
|
139
|
+
|
|
140
|
+
/** Whether a touch/pointer sequence is active */
|
|
141
|
+
touching: boolean;
|
|
142
|
+
|
|
143
|
+
/** Timestamp of last velocity sample */
|
|
144
|
+
lastTouchTime: number;
|
|
145
|
+
|
|
146
|
+
/** Scrollbar fade-out opacity (0–1) */
|
|
147
|
+
scrollbarOpacity: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
124
150
|
export interface DirtyRect extends Rectangle {
|
|
125
151
|
frameId: number;
|
|
126
152
|
}
|
package/src/canvas/utils.ts
CHANGED
|
@@ -7,8 +7,14 @@
|
|
|
7
7
|
import type { BoxSpacing, Rectangle, Point, VirtualNode } from "./types.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Parse spacing value (margin, padding)
|
|
11
|
-
*
|
|
10
|
+
* Parse spacing value (margin, padding).
|
|
11
|
+
*
|
|
12
|
+
* Supports the same forms as the DOM and native renderers:
|
|
13
|
+
* - `number` — all sides
|
|
14
|
+
* - `"10"`, `"10 20"`, `"10 20 30"`, `"10 20 30 40"` — CSS shorthand string
|
|
15
|
+
* - `{top, right, bottom, left}` — named keys
|
|
16
|
+
* - `{0, 1, ...}` — positional applicator args from the engine, mapped via
|
|
17
|
+
* CSS shorthand semantics (1=all, 2=v/h, 3=t/h/b, 4=t/r/b/l)
|
|
12
18
|
*/
|
|
13
19
|
export function parseSpacing(value: any): BoxSpacing {
|
|
14
20
|
if (typeof value === "number") {
|
|
@@ -17,29 +23,53 @@ export function parseSpacing(value: any): BoxSpacing {
|
|
|
17
23
|
|
|
18
24
|
if (typeof value === "string") {
|
|
19
25
|
const parts = value.split(/\s+/).map((v) => parseFloat(v) || 0);
|
|
20
|
-
|
|
21
|
-
return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
|
22
|
-
}
|
|
23
|
-
if (parts.length === 2) {
|
|
24
|
-
return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
|
25
|
-
}
|
|
26
|
-
if (parts.length === 4) {
|
|
27
|
-
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
|
28
|
-
}
|
|
26
|
+
return spacingFromArray(parts);
|
|
29
27
|
}
|
|
30
28
|
|
|
31
29
|
if (typeof value === "object" && value !== null) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
30
|
+
// Named-keys form takes priority when any side is named.
|
|
31
|
+
if (
|
|
32
|
+
value.top !== undefined ||
|
|
33
|
+
value.right !== undefined ||
|
|
34
|
+
value.bottom !== undefined ||
|
|
35
|
+
value.left !== undefined
|
|
36
|
+
) {
|
|
37
|
+
return {
|
|
38
|
+
top: parseFloat(value.top) || 0,
|
|
39
|
+
right: parseFloat(value.right) || 0,
|
|
40
|
+
bottom: parseFloat(value.bottom) || 0,
|
|
41
|
+
left: parseFloat(value.left) || 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Positional form: walk contiguous "0", "1", … keys.
|
|
46
|
+
const args: number[] = [];
|
|
47
|
+
for (let i = 0; value[String(i)] !== undefined; i++) {
|
|
48
|
+
args.push(parseFloat(value[String(i)]) || 0);
|
|
49
|
+
}
|
|
50
|
+
if (args.length > 0) {
|
|
51
|
+
return spacingFromArray(args);
|
|
52
|
+
}
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
41
56
|
}
|
|
42
57
|
|
|
58
|
+
/** CSS shorthand: 1=all, 2=v/h, 3=t/h/b, 4=t/r/b/l. */
|
|
59
|
+
function spacingFromArray(parts: number[]): BoxSpacing {
|
|
60
|
+
if (parts.length === 1) {
|
|
61
|
+
return { top: parts[0], right: parts[0], bottom: parts[0], left: parts[0] };
|
|
62
|
+
}
|
|
63
|
+
if (parts.length === 2) {
|
|
64
|
+
return { top: parts[0], right: parts[1], bottom: parts[0], left: parts[1] };
|
|
65
|
+
}
|
|
66
|
+
if (parts.length === 3) {
|
|
67
|
+
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[1] };
|
|
68
|
+
}
|
|
69
|
+
// 4+ values: top, right, bottom, left
|
|
70
|
+
return { top: parts[0], right: parts[1], bottom: parts[2], left: parts[3] };
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/**
|
|
44
74
|
* Parse size value (width, height)
|
|
45
75
|
* Returns pixel value or null for "auto"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windowed / Virtual Rendering
|
|
3
|
+
*
|
|
4
|
+
* For scrollable containers with many children, determines which children
|
|
5
|
+
* intersect the visible viewport so the paint system can skip off-screen nodes.
|
|
6
|
+
* This is paint-level virtualization only -- all children still get layout
|
|
7
|
+
* computed (Taffy handles that efficiently). We just skip painting off-screen
|
|
8
|
+
* children.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { VirtualNode, Rectangle } from "./types.js";
|
|
12
|
+
import { isScrollable } from "./scroll.js";
|
|
13
|
+
|
|
14
|
+
/** Number of pixels to render beyond the visible viewport edges. */
|
|
15
|
+
const OVERSCAN = 100;
|
|
16
|
+
|
|
17
|
+
/** Minimum child count before virtualization kicks in. */
|
|
18
|
+
export const VIRTUALIZE_THRESHOLD = 20;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Return the subset of `parent.children` whose layouts overlap the visible
|
|
22
|
+
* viewport of the parent container.
|
|
23
|
+
*
|
|
24
|
+
* - For non-scrollable containers every child is returned unchanged.
|
|
25
|
+
* - For scrollable containers the visible rect is the parent's layout bounds
|
|
26
|
+
* offset by the current scroll position, expanded by OVERSCAN pixels on
|
|
27
|
+
* each axis to prevent pop-in during fast scrolling.
|
|
28
|
+
*
|
|
29
|
+
* Children without a computed layout are always included (defensive -- they
|
|
30
|
+
* may need painting for side-effects).
|
|
31
|
+
*/
|
|
32
|
+
export function getVisibleChildren(
|
|
33
|
+
parent: VirtualNode,
|
|
34
|
+
viewport: Rectangle,
|
|
35
|
+
): VirtualNode[] {
|
|
36
|
+
if (!isScrollable(parent)) {
|
|
37
|
+
return parent.children;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const ss = parent.scrollState;
|
|
41
|
+
if (!ss) {
|
|
42
|
+
return parent.children;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The visible window in *content coordinates* (i.e. coordinates that
|
|
46
|
+
// children are laid out in). The viewport rectangle is already in the
|
|
47
|
+
// parent's coordinate space; we shift it by the scroll offset so that
|
|
48
|
+
// it matches where the children actually are in the layout.
|
|
49
|
+
const visibleMinX = viewport.x + ss.scrollX - OVERSCAN;
|
|
50
|
+
const visibleMaxX = viewport.x + ss.scrollX + viewport.width + OVERSCAN;
|
|
51
|
+
const visibleMinY = viewport.y + ss.scrollY - OVERSCAN;
|
|
52
|
+
const visibleMaxY = viewport.y + ss.scrollY + viewport.height + OVERSCAN;
|
|
53
|
+
|
|
54
|
+
const visible: VirtualNode[] = [];
|
|
55
|
+
|
|
56
|
+
for (const child of parent.children) {
|
|
57
|
+
// Always include children without layout (safety net).
|
|
58
|
+
if (!child.layout) {
|
|
59
|
+
visible.push(child);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const cl = child.layout;
|
|
64
|
+
const childMinX = cl.x;
|
|
65
|
+
const childMaxX = cl.x + cl.width;
|
|
66
|
+
const childMinY = cl.y;
|
|
67
|
+
const childMaxY = cl.y + cl.height;
|
|
68
|
+
|
|
69
|
+
// Standard AABB intersection test.
|
|
70
|
+
if (
|
|
71
|
+
childMaxX > visibleMinX &&
|
|
72
|
+
childMinX < visibleMaxX &&
|
|
73
|
+
childMaxY > visibleMinY &&
|
|
74
|
+
childMinY < visibleMaxY
|
|
75
|
+
) {
|
|
76
|
+
visible.push(child);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return visible;
|
|
81
|
+
}
|
|
@@ -274,7 +274,7 @@ export class ApplicatorRegistry {
|
|
|
274
274
|
* Apply multiple applicators
|
|
275
275
|
*/
|
|
276
276
|
applyAll(element: HTMLElement, applicators: Record<string, any>): void {
|
|
277
|
-
|
|
277
|
+
|
|
278
278
|
|
|
279
279
|
// Group applicators by their base name (treat applicators like components with arguments)
|
|
280
280
|
// e.g., "onClick.0", "onClick.id", "onClick.title" -> onClick: {0: ..., id: ..., title: ...}
|
|
@@ -12,21 +12,67 @@ const getNumericValue = (value: any): number | null => {
|
|
|
12
12
|
return null;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// Extract a CSS length value, supporting numbers, strings ("auto", "10px",
|
|
16
|
+
// percentages), or `{0: value}` positional form. Returns null if no value.
|
|
17
|
+
const getCssLengthValue = (value: any): string | null => {
|
|
18
|
+
if (value === null || value === undefined) return null;
|
|
19
|
+
if (typeof value === "number") return `${value}px`;
|
|
20
|
+
if (typeof value === "string") return value;
|
|
21
|
+
if (typeof value === "object") {
|
|
22
|
+
if (value["0"] !== undefined) {
|
|
23
|
+
const inner = value["0"];
|
|
24
|
+
if (typeof inner === "number") return `${inner}px`;
|
|
25
|
+
if (typeof inner === "string") return inner;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Format a value as a CSS length: numbers become "Npx", strings pass through
|
|
32
|
+
const toCssLength = (v: any): string => {
|
|
33
|
+
if (typeof v === "number") return `${v}px`;
|
|
34
|
+
return String(v);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Collect positional args ("0", "1", "2", "3") in order, stopping at the first gap.
|
|
38
|
+
const positionalArgs = (value: any): any[] => {
|
|
39
|
+
const args: any[] = [];
|
|
40
|
+
for (let i = 0; value[String(i)] !== undefined; i++) {
|
|
41
|
+
args.push(value[String(i)]);
|
|
42
|
+
}
|
|
43
|
+
return args;
|
|
44
|
+
};
|
|
45
|
+
|
|
15
46
|
export const marginHandler: ApplicatorHandler = (el, value) => {
|
|
16
47
|
if (typeof value === "number") {
|
|
17
48
|
el.style.margin = `${value}px`;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} else {
|
|
23
|
-
if (value.left !== undefined) el.style.marginLeft = `${value.left}px`;
|
|
24
|
-
if (value.right !== undefined) el.style.marginRight = `${value.right}px`;
|
|
25
|
-
if (value.top !== undefined) el.style.marginTop = `${value.top}px`;
|
|
26
|
-
if (value.bottom !== undefined) el.style.marginBottom = `${value.bottom}px`;
|
|
27
|
-
}
|
|
28
|
-
} else {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof value !== "object" || value === null) {
|
|
29
53
|
el.style.margin = String(value);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Named-keys form: { top, right, bottom, left }
|
|
58
|
+
if (
|
|
59
|
+
value.left !== undefined ||
|
|
60
|
+
value.right !== undefined ||
|
|
61
|
+
value.top !== undefined ||
|
|
62
|
+
value.bottom !== undefined
|
|
63
|
+
) {
|
|
64
|
+
if (value.left !== undefined) el.style.marginLeft = toCssLength(value.left);
|
|
65
|
+
if (value.right !== undefined) el.style.marginRight = toCssLength(value.right);
|
|
66
|
+
if (value.top !== undefined) el.style.marginTop = toCssLength(value.top);
|
|
67
|
+
if (value.bottom !== undefined) el.style.marginBottom = toCssLength(value.bottom);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Positional form mirrors CSS shorthand: .margin(v), .margin(v, h),
|
|
72
|
+
// .margin(t, h, b), .margin(t, r, b, l)
|
|
73
|
+
const args = positionalArgs(value);
|
|
74
|
+
if (args.length > 0) {
|
|
75
|
+
el.style.margin = args.map(toCssLength).join(" ");
|
|
30
76
|
}
|
|
31
77
|
};
|
|
32
78
|
|
|
@@ -12,21 +12,55 @@ const getNumericValue = (value: any): number | null => {
|
|
|
12
12
|
return null;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
// Format a value as a CSS length: numbers become "Npx", strings pass through
|
|
16
|
+
const toCssLength = (v: any): string => {
|
|
17
|
+
if (typeof v === "number") return `${v}px`;
|
|
18
|
+
return String(v);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Collect positional args ("0", "1", "2", "3") in order, stopping at the first
|
|
22
|
+
// gap. Returns an empty array if no positional args are present.
|
|
23
|
+
const positionalArgs = (value: any): any[] => {
|
|
24
|
+
const args: any[] = [];
|
|
25
|
+
for (let i = 0; value[String(i)] !== undefined; i++) {
|
|
26
|
+
args.push(value[String(i)]);
|
|
27
|
+
}
|
|
28
|
+
return args;
|
|
29
|
+
};
|
|
30
|
+
|
|
15
31
|
export const paddingHandler: ApplicatorHandler = (el, value) => {
|
|
16
32
|
if (typeof value === "number") {
|
|
17
33
|
el.style.padding = `${value}px`;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
} else {
|
|
23
|
-
if (value.left !== undefined) el.style.paddingLeft = `${value.left}px`;
|
|
24
|
-
if (value.right !== undefined) el.style.paddingRight = `${value.right}px`;
|
|
25
|
-
if (value.top !== undefined) el.style.paddingTop = `${value.top}px`;
|
|
26
|
-
if (value.bottom !== undefined) el.style.paddingBottom = `${value.bottom}px`;
|
|
27
|
-
}
|
|
28
|
-
} else {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value !== "object" || value === null) {
|
|
29
38
|
el.style.padding = String(value);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Named-keys form: { top, right, bottom, left }
|
|
43
|
+
if (
|
|
44
|
+
value.left !== undefined ||
|
|
45
|
+
value.right !== undefined ||
|
|
46
|
+
value.top !== undefined ||
|
|
47
|
+
value.bottom !== undefined
|
|
48
|
+
) {
|
|
49
|
+
if (value.left !== undefined) el.style.paddingLeft = toCssLength(value.left);
|
|
50
|
+
if (value.right !== undefined) el.style.paddingRight = toCssLength(value.right);
|
|
51
|
+
if (value.top !== undefined) el.style.paddingTop = toCssLength(value.top);
|
|
52
|
+
if (value.bottom !== undefined) el.style.paddingBottom = toCssLength(value.bottom);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Positional form: .padding(v), .padding(v, h), .padding(t, h, b), .padding(t, r, b, l)
|
|
57
|
+
// Mirrors CSS shorthand semantics so users can write
|
|
58
|
+
// .padding(10, 16) // -> 10px 16px
|
|
59
|
+
// .padding(8, 12, 4) // -> 8px 12px 4px
|
|
60
|
+
// .padding(8, 12, 4, 6) // -> 8px 12px 4px 6px
|
|
61
|
+
const args = positionalArgs(value);
|
|
62
|
+
if (args.length > 0) {
|
|
63
|
+
el.style.padding = args.map(toCssLength).join(" ");
|
|
30
64
|
}
|
|
31
65
|
};
|
|
32
66
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icon Component
|
|
3
|
+
*
|
|
4
|
+
* Renders SVG icons from pre-resolved path data injected by the engine.
|
|
5
|
+
* The engine resolves Icon("heart") → SVG paths at render time and sends
|
|
6
|
+
* the path data in the Create patch props. This handler just renders them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ComponentHandler } from "./index.js";
|
|
10
|
+
|
|
11
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
12
|
+
|
|
13
|
+
export const iconHandler: ComponentHandler = {
|
|
14
|
+
create(): HTMLElement {
|
|
15
|
+
const el = document.createElement("span");
|
|
16
|
+
el.style.display = "inline-flex";
|
|
17
|
+
el.style.alignItems = "center";
|
|
18
|
+
el.style.justifyContent = "center";
|
|
19
|
+
el.style.verticalAlign = "middle";
|
|
20
|
+
el.dataset.hypenType = "icon";
|
|
21
|
+
return el;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
applyProps(el: HTMLElement, props: Record<string, any>): void {
|
|
25
|
+
// Server-resolved icon data (from engine's ResourceRegistry)
|
|
26
|
+
const paths: Array<{
|
|
27
|
+
d: string;
|
|
28
|
+
fill?: string;
|
|
29
|
+
stroke?: string;
|
|
30
|
+
strokeWidth?: number;
|
|
31
|
+
strokeLinecap?: string;
|
|
32
|
+
strokeLinejoin?: string;
|
|
33
|
+
}> = props.__iconPaths;
|
|
34
|
+
const viewBox: string = props.__iconViewBox || "0 0 24 24";
|
|
35
|
+
|
|
36
|
+
// Size and color from DSL props
|
|
37
|
+
const size = props.size || props["size.0"] || 24;
|
|
38
|
+
const color = props.color || props["color.0"] || "currentColor";
|
|
39
|
+
|
|
40
|
+
if (paths && Array.isArray(paths)) {
|
|
41
|
+
// Render from pre-resolved SVG path data
|
|
42
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
43
|
+
svg.setAttribute("xmlns", SVG_NS);
|
|
44
|
+
svg.setAttribute("width", String(size));
|
|
45
|
+
svg.setAttribute("height", String(size));
|
|
46
|
+
svg.setAttribute("viewBox", viewBox);
|
|
47
|
+
svg.setAttribute("fill", "none");
|
|
48
|
+
svg.style.display = "block";
|
|
49
|
+
|
|
50
|
+
for (const pathData of paths) {
|
|
51
|
+
const path = document.createElementNS(SVG_NS, "path");
|
|
52
|
+
path.setAttribute("d", pathData.d);
|
|
53
|
+
path.setAttribute("fill", pathData.fill || "none");
|
|
54
|
+
path.setAttribute(
|
|
55
|
+
"stroke",
|
|
56
|
+
(pathData.stroke || "currentColor") === "currentColor"
|
|
57
|
+
? color
|
|
58
|
+
: pathData.stroke || color
|
|
59
|
+
);
|
|
60
|
+
if (pathData.strokeWidth != null) {
|
|
61
|
+
path.setAttribute("stroke-width", String(pathData.strokeWidth));
|
|
62
|
+
}
|
|
63
|
+
if (pathData.strokeLinecap) {
|
|
64
|
+
path.setAttribute("stroke-linecap", pathData.strokeLinecap);
|
|
65
|
+
}
|
|
66
|
+
if (pathData.strokeLinejoin) {
|
|
67
|
+
path.setAttribute("stroke-linejoin", pathData.strokeLinejoin);
|
|
68
|
+
}
|
|
69
|
+
svg.appendChild(path);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
el.innerHTML = "";
|
|
73
|
+
el.appendChild(svg);
|
|
74
|
+
} else {
|
|
75
|
+
// Fallback: no resolved icon data — show placeholder
|
|
76
|
+
const name = props["0"] || props.name || "?";
|
|
77
|
+
el.textContent = name;
|
|
78
|
+
el.style.width = `${size}px`;
|
|
79
|
+
el.style.height = `${size}px`;
|
|
80
|
+
el.style.fontSize = `${Math.round(Number(size) * 0.6)}px`;
|
|
81
|
+
el.style.color = color;
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -83,8 +83,10 @@ export class ComponentRegistry {
|
|
|
83
83
|
const { routeHandler } = require("./route.js");
|
|
84
84
|
const { hypenAppHandler } = require("./hypenapp.js");
|
|
85
85
|
const { appHandler } = require("./app.js");
|
|
86
|
+
const { iconHandler } = require("./icon.js");
|
|
86
87
|
|
|
87
88
|
this.register("app", appHandler);
|
|
89
|
+
this.register("icon", iconHandler);
|
|
88
90
|
this.register("column", columnHandler);
|
|
89
91
|
this.register("row", rowHandler);
|
|
90
92
|
this.register("text", textHandler);
|