@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/dist/canvas/paint.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Drawing virtual nodes to canvas
|
|
5
5
|
*/
|
|
6
|
-
import { renderText } from "./text.js";
|
|
6
|
+
import { measureText, renderText } from "./text.js";
|
|
7
7
|
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
8
8
|
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
9
|
-
import {
|
|
9
|
+
import { SELECTION_HIGHLIGHT_COLOR, offsetToCaretRect, rangeToRects, } from "./text-geometry.js";
|
|
10
|
+
import { cssLengthToPx } from "./utils.js";
|
|
10
11
|
/**
|
|
11
12
|
* Module-level reference to the active SelectionManager so paintText
|
|
12
13
|
* can render selection highlights. Set once from the renderer.
|
|
@@ -15,6 +16,15 @@ let activeSelectionManager = null;
|
|
|
15
16
|
export function setSelectionManager(mgr) {
|
|
16
17
|
activeSelectionManager = mgr;
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Module-level reference to the active TextEditController so paintInput can
|
|
21
|
+
* render the edited value, selection highlight, composition underline, and
|
|
22
|
+
* blinking caret. Same late-binding pattern as the SelectionManager hook.
|
|
23
|
+
*/
|
|
24
|
+
let activeTextEditor = null;
|
|
25
|
+
export function setTextEditor(editor) {
|
|
26
|
+
activeTextEditor = editor;
|
|
27
|
+
}
|
|
18
28
|
/**
|
|
19
29
|
* Custom painters registry
|
|
20
30
|
*/
|
|
@@ -26,27 +36,156 @@ export function registerPainter(type, painter) {
|
|
|
26
36
|
customPainters.set(type.toLowerCase(), painter);
|
|
27
37
|
}
|
|
28
38
|
/**
|
|
29
|
-
*
|
|
39
|
+
* Per-font character advance widths, used by the manual letter-spacing path
|
|
40
|
+
* so each glyph is shaped once per font instead of once per frame.
|
|
41
|
+
*/
|
|
42
|
+
const charAdvanceCache = new Map();
|
|
43
|
+
const MAX_CHAR_ADVANCE_FONTS = 32;
|
|
44
|
+
/**
|
|
45
|
+
* Clear cached per-character advances. Must be called when web fonts finish
|
|
46
|
+
* loading: the cache is keyed by CSS font string, which is identical before
|
|
47
|
+
* and after a webfont replaces its fallback, so advances measured against
|
|
48
|
+
* the fallback font would otherwise poison letter-spaced rendering
|
|
49
|
+
* permanently.
|
|
50
|
+
*/
|
|
51
|
+
export function clearCharAdvanceCache() {
|
|
52
|
+
charAdvanceCache.clear();
|
|
53
|
+
}
|
|
54
|
+
function charAdvance(ctx, font, ch) {
|
|
55
|
+
let perFont = charAdvanceCache.get(font);
|
|
56
|
+
if (!perFont) {
|
|
57
|
+
perFont = new Map();
|
|
58
|
+
charAdvanceCache.set(font, perFont);
|
|
59
|
+
evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
|
|
60
|
+
}
|
|
61
|
+
let width = perFont.get(ch);
|
|
62
|
+
if (width === undefined) {
|
|
63
|
+
width = ctx.measureText(ch).width;
|
|
64
|
+
perFont.set(ch, width);
|
|
65
|
+
}
|
|
66
|
+
return width;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Component types whose painters set sticky canvas state (textAlign,
|
|
70
|
+
* textBaseline, lineCap, shadows, …) that must not leak to siblings — they
|
|
71
|
+
* need a scoping save/restore pair. Containers, text, and spacers (and
|
|
72
|
+
* every unknown type, which routes to paintContainer) only touch state each
|
|
73
|
+
* draw call re-sets before use, so they paint without one.
|
|
74
|
+
*/
|
|
75
|
+
const STATE_UNSAFE_TYPES = new Set([
|
|
76
|
+
"button",
|
|
77
|
+
"input",
|
|
78
|
+
"textarea",
|
|
79
|
+
"image",
|
|
80
|
+
"divider",
|
|
81
|
+
"separator",
|
|
82
|
+
"checkbox",
|
|
83
|
+
"radio",
|
|
84
|
+
"switch",
|
|
85
|
+
"toggle",
|
|
86
|
+
"slider",
|
|
87
|
+
"progress",
|
|
88
|
+
"progressbar",
|
|
89
|
+
"spinner",
|
|
90
|
+
"loading",
|
|
91
|
+
"card",
|
|
92
|
+
"badge",
|
|
93
|
+
"avatar",
|
|
94
|
+
"icon",
|
|
95
|
+
"link",
|
|
96
|
+
]);
|
|
97
|
+
/** Whether any transform-related prop is present on the node. */
|
|
98
|
+
function hasTransformProps(props) {
|
|
99
|
+
return (props.transform !== undefined ||
|
|
100
|
+
props.translateX !== undefined ||
|
|
101
|
+
props.translateY !== undefined ||
|
|
102
|
+
props.rotate !== undefined ||
|
|
103
|
+
props.scale !== undefined ||
|
|
104
|
+
props.scaleX !== undefined ||
|
|
105
|
+
props.scaleY !== undefined ||
|
|
106
|
+
props.skew !== undefined ||
|
|
107
|
+
props.skewX !== undefined ||
|
|
108
|
+
props.skewY !== undefined);
|
|
109
|
+
}
|
|
110
|
+
/** Extra pixels a node's painting may reach beyond its layout box. */
|
|
111
|
+
const CULL_SLACK = 8;
|
|
112
|
+
/** How far a shadow can bleed outside the box it is attached to. */
|
|
113
|
+
function shadowExtent(shadow) {
|
|
114
|
+
if (typeof shadow === "string") {
|
|
115
|
+
const parts = shadow.trim().split(/\s+/);
|
|
116
|
+
const ox = Math.abs(parseFloat(parts[0])) || 0;
|
|
117
|
+
const oy = Math.abs(parseFloat(parts[1])) || 0;
|
|
118
|
+
const blur = Math.abs(parseFloat(parts[2])) || 0;
|
|
119
|
+
return Math.max(ox, oy) + blur;
|
|
120
|
+
}
|
|
121
|
+
if (shadow && typeof shadow === "object") {
|
|
122
|
+
return (Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
|
|
123
|
+
Math.abs(shadow.blur || 0));
|
|
124
|
+
}
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Whether the whole subtree rooted at `node` can be skipped when repainting
|
|
129
|
+
* only `cull`. Conservative: nodes with transforms or custom painters are
|
|
130
|
+
* never culled (they can draw anywhere), the box is expanded by shadow
|
|
131
|
+
* bleed + slack, and a node with children is only culled when it clips them.
|
|
132
|
+
*/
|
|
133
|
+
function canCullSubtree(node, cull) {
|
|
134
|
+
const layout = node.layout;
|
|
135
|
+
if (hasTransformProps(node.props))
|
|
136
|
+
return false;
|
|
137
|
+
if (customPainters.has(node.type.toLowerCase()))
|
|
138
|
+
return false;
|
|
139
|
+
const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
|
|
140
|
+
const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
|
|
141
|
+
if (layout.x - margin < cull.x + cull.width &&
|
|
142
|
+
layout.x + layout.width + margin > cull.x &&
|
|
143
|
+
layout.y - margin < cull.y + cull.height &&
|
|
144
|
+
layout.y + layout.height + margin > cull.y) {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (node.children.length === 0)
|
|
148
|
+
return true;
|
|
149
|
+
const overflow = node.props.overflow;
|
|
150
|
+
return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Paint a virtual node and its children.
|
|
154
|
+
*
|
|
155
|
+
* `cull` (optional) is the dirty region being repainted — subtrees that
|
|
156
|
+
* provably cannot reach it are skipped entirely.
|
|
30
157
|
*/
|
|
31
|
-
export function paintNode(ctx, node) {
|
|
158
|
+
export function paintNode(ctx, node, cull) {
|
|
32
159
|
if (!node.visible || !node.layout)
|
|
33
160
|
return;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
161
|
+
if (cull && canCullSubtree(node, cull))
|
|
162
|
+
return;
|
|
163
|
+
const type = node.type.toLowerCase();
|
|
164
|
+
const customPainter = customPainters.get(type);
|
|
165
|
+
const needsTransform = hasTransformProps(node.props);
|
|
166
|
+
const needsSave = customPainter !== undefined ||
|
|
167
|
+
needsTransform ||
|
|
168
|
+
node.opacity < 1 ||
|
|
169
|
+
STATE_UNSAFE_TYPES.has(type);
|
|
170
|
+
if (needsSave) {
|
|
171
|
+
ctx.save();
|
|
172
|
+
// Apply transforms
|
|
173
|
+
if (needsTransform) {
|
|
174
|
+
applyTransforms(ctx, node);
|
|
175
|
+
}
|
|
176
|
+
// Apply opacity
|
|
177
|
+
if (node.opacity < 1) {
|
|
178
|
+
ctx.globalAlpha = node.opacity;
|
|
179
|
+
}
|
|
40
180
|
}
|
|
41
181
|
// Check for custom painter
|
|
42
|
-
const customPainter = customPainters.get(node.type.toLowerCase());
|
|
43
182
|
if (customPainter) {
|
|
44
183
|
customPainter(ctx, node);
|
|
45
184
|
ctx.restore();
|
|
46
185
|
return;
|
|
47
186
|
}
|
|
48
187
|
// Default painting based on type
|
|
49
|
-
switch (
|
|
188
|
+
switch (type) {
|
|
50
189
|
case "column":
|
|
51
190
|
case "row":
|
|
52
191
|
case "stack":
|
|
@@ -59,6 +198,7 @@ export function paintNode(ctx, node) {
|
|
|
59
198
|
paintButton(ctx, node);
|
|
60
199
|
break;
|
|
61
200
|
case "input":
|
|
201
|
+
case "textarea":
|
|
62
202
|
paintInput(ctx, node);
|
|
63
203
|
break;
|
|
64
204
|
case "image":
|
|
@@ -115,13 +255,25 @@ export function paintNode(ctx, node) {
|
|
|
115
255
|
default:
|
|
116
256
|
paintContainer(ctx, node);
|
|
117
257
|
}
|
|
118
|
-
|
|
258
|
+
if (needsSave) {
|
|
259
|
+
ctx.restore();
|
|
260
|
+
}
|
|
119
261
|
// Apply scroll translation for scrollable containers
|
|
120
262
|
const ss = node.scrollState;
|
|
263
|
+
let childCull = cull ?? null;
|
|
121
264
|
if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
|
|
122
265
|
ctx.save();
|
|
123
266
|
ctx.translate(-ss.scrollX, -ss.scrollY);
|
|
124
267
|
node._scrollRestore = true;
|
|
268
|
+
// Children paint in scrolled space — shift the cull region to match.
|
|
269
|
+
if (childCull) {
|
|
270
|
+
childCull = {
|
|
271
|
+
x: childCull.x + ss.scrollX,
|
|
272
|
+
y: childCull.y + ss.scrollY,
|
|
273
|
+
width: childCull.width,
|
|
274
|
+
height: childCull.height,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
125
277
|
}
|
|
126
278
|
// Paint children -- use windowed rendering for large scrollable lists
|
|
127
279
|
const shouldVirtualize = isScrollable(node) &&
|
|
@@ -141,18 +293,21 @@ export function paintNode(ctx, node) {
|
|
|
141
293
|
// tree (Story uses this for its close button) gets covered by the
|
|
142
294
|
// following in-flow Image. Push absolute kids to the end so they land
|
|
143
295
|
// on top — same effect as `z-index: auto` painting absolute after flow.
|
|
144
|
-
|
|
145
|
-
const overlayKids = [];
|
|
296
|
+
let hasOverlays = false;
|
|
146
297
|
for (const child of childrenToPaint) {
|
|
147
|
-
if (child.props.position === "absolute")
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
298
|
+
if (child.props.position === "absolute") {
|
|
299
|
+
hasOverlays = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
paintNode(ctx, child, childCull);
|
|
303
|
+
}
|
|
304
|
+
if (hasOverlays) {
|
|
305
|
+
for (const child of childrenToPaint) {
|
|
306
|
+
if (child.props.position === "absolute") {
|
|
307
|
+
paintNode(ctx, child, childCull);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
151
310
|
}
|
|
152
|
-
for (const child of flowKids)
|
|
153
|
-
paintNode(ctx, child);
|
|
154
|
-
for (const child of overlayKids)
|
|
155
|
-
paintNode(ctx, child);
|
|
156
311
|
// Undo scroll translation before drawing scrollbars
|
|
157
312
|
if (node._scrollRestore) {
|
|
158
313
|
ctx.restore();
|
|
@@ -185,15 +340,9 @@ function paintContainer(ctx, node) {
|
|
|
185
340
|
applyShadow(ctx, shadow);
|
|
186
341
|
}
|
|
187
342
|
// Draw background
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
|
|
191
|
-
if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
|
|
192
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
193
|
-
}
|
|
194
|
-
else {
|
|
195
|
-
ctx.fillStyle = backgroundColor;
|
|
196
|
-
}
|
|
343
|
+
const background = resolveBackgroundPaint(props);
|
|
344
|
+
if (background) {
|
|
345
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, background, x, y, width, height);
|
|
197
346
|
if (radius > 0) {
|
|
198
347
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
199
348
|
ctx.fill();
|
|
@@ -247,14 +396,13 @@ function paintText(ctx, node) {
|
|
|
247
396
|
}
|
|
248
397
|
const layout = node.layout;
|
|
249
398
|
const props = node.props;
|
|
250
|
-
|
|
251
|
-
let text = String(props[0] ?? props.text ?? "");
|
|
399
|
+
let text = String(props[0] || props.text || "");
|
|
252
400
|
const color = props.color || "#000000";
|
|
253
401
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
254
402
|
const fontWeight = props.fontWeight || "normal";
|
|
255
403
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
256
404
|
const textAlign = props.textAlign || "left";
|
|
257
|
-
const lineHeight =
|
|
405
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
258
406
|
const textDecoration = props.textDecoration || "none";
|
|
259
407
|
const textTransform = props.textTransform || "none";
|
|
260
408
|
const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
|
|
@@ -279,13 +427,14 @@ function paintText(ctx, node) {
|
|
|
279
427
|
if (letterSpacing !== 0) {
|
|
280
428
|
// Manual letter spacing
|
|
281
429
|
ctx.fillStyle = color;
|
|
282
|
-
|
|
430
|
+
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
431
|
+
ctx.font = font;
|
|
283
432
|
ctx.textAlign = "left";
|
|
284
433
|
ctx.textBaseline = "top";
|
|
285
434
|
let currentX = x;
|
|
286
435
|
for (let i = 0; i < text.length; i++) {
|
|
287
436
|
ctx.fillText(text[i], currentX, y);
|
|
288
|
-
currentX += ctx
|
|
437
|
+
currentX += charAdvance(ctx, font, text[i]);
|
|
289
438
|
// Add letter spacing after each character except the last
|
|
290
439
|
if (i < text.length - 1) {
|
|
291
440
|
currentX += letterSpacing;
|
|
@@ -315,11 +464,11 @@ function paintText(ctx, node) {
|
|
|
315
464
|
verticalAlign: "top",
|
|
316
465
|
lineHeight,
|
|
317
466
|
}, maxLines, textOverflow);
|
|
318
|
-
// Text decoration
|
|
467
|
+
// Text decoration — reuse the wrapped metrics renderText just resolved
|
|
468
|
+
// (cache hit) instead of re-shaping the raw, unwrapped string.
|
|
319
469
|
if (textDecoration !== "none") {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
|
|
470
|
+
const metrics = measureText(ctx, text, { fontSize, fontWeight, fontFamily, lineHeight }, layout.contentWidth, maxLines, textOverflow);
|
|
471
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
|
|
323
472
|
}
|
|
324
473
|
}
|
|
325
474
|
// Reset shadow
|
|
@@ -340,7 +489,7 @@ function paintButton(ctx, node) {
|
|
|
340
489
|
const y = layout.y;
|
|
341
490
|
const width = layout.width;
|
|
342
491
|
const height = layout.height;
|
|
343
|
-
const radius = layout.border.radius;
|
|
492
|
+
const radius = layout.border.radius || 4;
|
|
344
493
|
// Apply shadow if specified
|
|
345
494
|
const shadow = props.shadow || props.boxShadow;
|
|
346
495
|
if (shadow) {
|
|
@@ -359,16 +508,11 @@ function paintButton(ctx, node) {
|
|
|
359
508
|
else if (node.focused && props.focusColor !== undefined) {
|
|
360
509
|
backgroundColor = props.focusColor;
|
|
361
510
|
}
|
|
362
|
-
else
|
|
363
|
-
backgroundColor = props
|
|
511
|
+
else {
|
|
512
|
+
backgroundColor = resolveBackgroundPaint(props);
|
|
364
513
|
}
|
|
365
514
|
if (backgroundColor !== undefined) {
|
|
366
|
-
|
|
367
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
368
|
-
}
|
|
369
|
-
else {
|
|
370
|
-
ctx.fillStyle = backgroundColor;
|
|
371
|
-
}
|
|
515
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
|
|
372
516
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
373
517
|
ctx.fill();
|
|
374
518
|
}
|
|
@@ -386,6 +530,17 @@ function paintButton(ctx, node) {
|
|
|
386
530
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
387
531
|
ctx.stroke();
|
|
388
532
|
}
|
|
533
|
+
// Keyboard focus ring. Focus lives on the node's (unrendered) mirror
|
|
534
|
+
// element in the canvas fallback content, so the UA can't draw a ring at
|
|
535
|
+
// the painted box — paint one so keyboard users can see where they are.
|
|
536
|
+
if (node.focused) {
|
|
537
|
+
ctx.save();
|
|
538
|
+
ctx.strokeStyle = props.focusRingColor || "#007bff";
|
|
539
|
+
ctx.lineWidth = 2;
|
|
540
|
+
drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
|
|
541
|
+
ctx.stroke();
|
|
542
|
+
ctx.restore();
|
|
543
|
+
}
|
|
389
544
|
// Paint children (typically Text)
|
|
390
545
|
for (const child of node.children) {
|
|
391
546
|
paintNode(ctx, child);
|
|
@@ -401,7 +556,7 @@ function paintInput(ctx, node) {
|
|
|
401
556
|
const y = layout.y;
|
|
402
557
|
const width = layout.width;
|
|
403
558
|
const height = layout.height;
|
|
404
|
-
const radius = layout.border.radius;
|
|
559
|
+
const radius = layout.border.radius || 4;
|
|
405
560
|
// Background
|
|
406
561
|
ctx.fillStyle = props.backgroundColor || "#ffffff";
|
|
407
562
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
@@ -413,26 +568,82 @@ function paintInput(ctx, node) {
|
|
|
413
568
|
ctx.lineWidth = borderWidth;
|
|
414
569
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
415
570
|
ctx.stroke();
|
|
416
|
-
|
|
417
|
-
|
|
571
|
+
const isSingleLine = node.type.toLowerCase() === "input";
|
|
572
|
+
// While editing, the mirror element (via TextEditController) is the value
|
|
573
|
+
// source — paint stays ahead of the engine's SetProp echo.
|
|
574
|
+
const edit = activeTextEditor?.getStateFor(node) ?? null;
|
|
575
|
+
const value = edit ? edit.value : (props.value || "");
|
|
418
576
|
const placeholder = props.placeholder || "";
|
|
419
577
|
const text = value || placeholder;
|
|
420
578
|
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
579
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
580
|
+
const fontWeight = props.fontWeight || "normal";
|
|
581
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
582
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
583
|
+
if (!edit) {
|
|
584
|
+
if (text) {
|
|
585
|
+
renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
|
|
586
|
+
color: textColor,
|
|
587
|
+
fontSize,
|
|
588
|
+
fontWeight,
|
|
589
|
+
fontFamily,
|
|
590
|
+
textAlign: "left",
|
|
591
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
592
|
+
lineHeight,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
// ---- Editing visuals: clip → selection highlight → text → composition
|
|
598
|
+
// ---- underline → caret. Geometry comes from the edit controller so
|
|
599
|
+
// ---- pointer mapping and painting share identical math (incl. scrollX).
|
|
600
|
+
ctx.save();
|
|
601
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
602
|
+
ctx.clip();
|
|
603
|
+
const g = activeTextEditor.geometry(layout.x + layout.contentX - edit.scrollX, layout.y + layout.contentY);
|
|
604
|
+
// Selection highlight behind the text
|
|
605
|
+
if (edit.selStart !== edit.selEnd) {
|
|
606
|
+
const from = Math.min(edit.selStart, edit.selEnd);
|
|
607
|
+
const to = Math.max(edit.selStart, edit.selEnd);
|
|
608
|
+
ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
|
|
609
|
+
for (const r of rangeToRects(ctx, g, from, to)) {
|
|
610
|
+
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
421
613
|
if (text) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
renderText(ctx, text, layout.x + layout.contentX, layout.y + layout.contentY, layout.contentWidth, layout.contentHeight, {
|
|
614
|
+
renderText(ctx, value || placeholder, g.contentX, layout.y + layout.contentY,
|
|
615
|
+
// Single-line inputs never wrap — the clip + scrollX pan handle
|
|
616
|
+
// overflow. Textareas wrap at content width like static paint.
|
|
617
|
+
isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth, layout.contentHeight, {
|
|
427
618
|
color: textColor,
|
|
428
619
|
fontSize,
|
|
429
620
|
fontWeight,
|
|
430
621
|
fontFamily,
|
|
431
622
|
textAlign: "left",
|
|
432
|
-
verticalAlign: "middle",
|
|
623
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
433
624
|
lineHeight,
|
|
434
625
|
});
|
|
435
626
|
}
|
|
627
|
+
// IME composition underline (dashed, under the composing range)
|
|
628
|
+
if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
|
|
629
|
+
ctx.strokeStyle = props.color || "#000000";
|
|
630
|
+
ctx.lineWidth = 1;
|
|
631
|
+
ctx.setLineDash([2, 2]);
|
|
632
|
+
for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
|
|
633
|
+
ctx.beginPath();
|
|
634
|
+
ctx.moveTo(r.x, r.y + r.height - 2);
|
|
635
|
+
ctx.lineTo(r.x + r.width, r.y + r.height - 2);
|
|
636
|
+
ctx.stroke();
|
|
637
|
+
}
|
|
638
|
+
ctx.setLineDash([]);
|
|
639
|
+
}
|
|
640
|
+
// Caret (hidden while a range is selected, blinks via the edit timer)
|
|
641
|
+
if (edit.caretVisible && edit.selStart === edit.selEnd) {
|
|
642
|
+
const caret = offsetToCaretRect(ctx, g, edit.selEnd);
|
|
643
|
+
ctx.fillStyle = props.color || "#000000";
|
|
644
|
+
ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
|
|
645
|
+
}
|
|
646
|
+
ctx.restore();
|
|
436
647
|
}
|
|
437
648
|
/**
|
|
438
649
|
* Image cache for canvas renderer
|
|
@@ -584,8 +795,10 @@ function paintImage(ctx, node) {
|
|
|
584
795
|
imagePending.delete(src);
|
|
585
796
|
imageFailCache.delete(src);
|
|
586
797
|
evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
|
|
587
|
-
// Request a repaint so the loaded image is drawn
|
|
588
|
-
|
|
798
|
+
// Request a repaint so the loaded image is drawn. `layout: true`
|
|
799
|
+
// because the decoded intrinsic size can change the image's box
|
|
800
|
+
// (see getImageNaturalAspect in layout.ts).
|
|
801
|
+
canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
|
|
589
802
|
};
|
|
590
803
|
img.onerror = () => {
|
|
591
804
|
imageInFlight.delete(src);
|
|
@@ -636,14 +849,6 @@ function drawImageCover(ctx, img, dx, dy, dw, dh) {
|
|
|
636
849
|
*/
|
|
637
850
|
function drawRoundedRect(ctx, x, y, width, height, radius) {
|
|
638
851
|
if (radius <= 0 || width <= 0 || height <= 0) {
|
|
639
|
-
// beginPath is critical here — without it, this rect appends to the
|
|
640
|
-
// existing path and the next `ctx.fill()` re-fills every previously
|
|
641
|
-
// queued rect with the current fillStyle. That manifested as a
|
|
642
|
-
// calculator grid where every button painted in the last button's
|
|
643
|
-
// colour and only the last button's child text survived (each
|
|
644
|
-
// subsequent fill repainted over earlier children). Mirrors the
|
|
645
|
-
// beginPath() the rounded branch already does below.
|
|
646
|
-
ctx.beginPath();
|
|
647
852
|
ctx.rect(x, y, width, height);
|
|
648
853
|
return;
|
|
649
854
|
}
|
|
@@ -690,6 +895,53 @@ function applyShadow(ctx, shadow) {
|
|
|
690
895
|
ctx.shadowColor = shadow.color || "rgba(0,0,0,0.3)";
|
|
691
896
|
}
|
|
692
897
|
}
|
|
898
|
+
/**
|
|
899
|
+
* Resolve DOM background applicator props into the CSS-like paint strings
|
|
900
|
+
* Canvas understands. DOM applicators map `.linearGradient("...")` to
|
|
901
|
+
* `background-image: linear-gradient(...)`; Canvas receives the original prop
|
|
902
|
+
* and must do the same translation before painting.
|
|
903
|
+
*/
|
|
904
|
+
function resolveBackgroundPaint(props) {
|
|
905
|
+
if (props.linearGradient !== undefined) {
|
|
906
|
+
return `linear-gradient(${props.linearGradient})`;
|
|
907
|
+
}
|
|
908
|
+
if (props.radialGradient !== undefined) {
|
|
909
|
+
return `radial-gradient(${props.radialGradient})`;
|
|
910
|
+
}
|
|
911
|
+
if (props.conicGradient !== undefined) {
|
|
912
|
+
return `conic-gradient(${props.conicGradient})`;
|
|
913
|
+
}
|
|
914
|
+
if (props.backgroundImage !== undefined) {
|
|
915
|
+
return String(props.backgroundImage);
|
|
916
|
+
}
|
|
917
|
+
if (props.backgroundColor !== undefined) {
|
|
918
|
+
return String(props.backgroundColor);
|
|
919
|
+
}
|
|
920
|
+
if (props.background !== undefined) {
|
|
921
|
+
return String(props.background);
|
|
922
|
+
}
|
|
923
|
+
return undefined;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Parsed gradients keyed by (source string, box geometry). CanvasGradient
|
|
927
|
+
* objects are not bound to a specific context, so identical fills across
|
|
928
|
+
* frames — the common case, since layout geometry is stable — skip the
|
|
929
|
+
* regex parse and createLinearGradient/createRadialGradient entirely.
|
|
930
|
+
*/
|
|
931
|
+
const gradientCache = new Map();
|
|
932
|
+
const MAX_GRADIENT_CACHE_SIZE = 256;
|
|
933
|
+
function resolveCanvasPaint(ctx, paint, x, y, width, height) {
|
|
934
|
+
if (!paint.includes("gradient("))
|
|
935
|
+
return paint;
|
|
936
|
+
const key = `${paint}|${x}|${y}|${width}|${height}`;
|
|
937
|
+
let resolved = gradientCache.get(key);
|
|
938
|
+
if (resolved === undefined) {
|
|
939
|
+
resolved = parseGradient(ctx, paint, x, y, width, height);
|
|
940
|
+
gradientCache.set(key, resolved);
|
|
941
|
+
evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
|
|
942
|
+
}
|
|
943
|
+
return resolved;
|
|
944
|
+
}
|
|
693
945
|
/**
|
|
694
946
|
* Parse gradient string and create canvas gradient
|
|
695
947
|
* Supports:
|
|
@@ -703,36 +955,25 @@ function parseGradient(ctx, gradientStr, x, y, width, height) {
|
|
|
703
955
|
const match = gradientStr.match(/linear-gradient\((.*)\)/);
|
|
704
956
|
if (!match)
|
|
705
957
|
return gradientStr;
|
|
706
|
-
const parts = match[1]
|
|
958
|
+
const parts = splitCssArgs(match[1]);
|
|
707
959
|
// Determine direction (default to bottom)
|
|
708
960
|
let x0 = x, y0 = y, x1 = x, y1 = y + height;
|
|
709
961
|
let colorStart = 0;
|
|
710
962
|
if (parts[0].includes("deg") || parts[0].includes("to ")) {
|
|
711
963
|
colorStart = 1;
|
|
712
964
|
const direction = parts[0];
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
y0 = y;
|
|
721
|
-
y1 = y;
|
|
722
|
-
}
|
|
723
|
-
else if (direction.includes("to top") || direction === "0deg") {
|
|
724
|
-
y0 = y + height;
|
|
725
|
-
y1 = y;
|
|
965
|
+
const angle = parseCssGradientAngle(direction);
|
|
966
|
+
if (angle !== null) {
|
|
967
|
+
const line = gradientLineForAngle(angle, x, y, width, height);
|
|
968
|
+
x0 = line.x0;
|
|
969
|
+
y0 = line.y0;
|
|
970
|
+
x1 = line.x1;
|
|
971
|
+
y1 = line.y1;
|
|
726
972
|
}
|
|
727
|
-
// Default is "to bottom" which we already set
|
|
728
973
|
}
|
|
729
974
|
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
730
975
|
// Add color stops
|
|
731
|
-
|
|
732
|
-
colors.forEach((color, i) => {
|
|
733
|
-
const stop = i / (colors.length - 1);
|
|
734
|
-
gradient.addColorStop(stop, color.trim());
|
|
735
|
-
});
|
|
976
|
+
addColorStops(gradient, parts.slice(colorStart));
|
|
736
977
|
return gradient;
|
|
737
978
|
}
|
|
738
979
|
else if (gradientStr.startsWith("radial-gradient")) {
|
|
@@ -740,21 +981,125 @@ function parseGradient(ctx, gradientStr, x, y, width, height) {
|
|
|
740
981
|
const match = gradientStr.match(/radial-gradient\((.*)\)/);
|
|
741
982
|
if (!match)
|
|
742
983
|
return gradientStr;
|
|
743
|
-
const parts = match[1]
|
|
984
|
+
const parts = splitCssArgs(match[1]);
|
|
744
985
|
// Create radial gradient from center
|
|
745
986
|
const centerX = x + width / 2;
|
|
746
987
|
const centerY = y + height / 2;
|
|
747
988
|
const radius = Math.max(width, height) / 2;
|
|
748
989
|
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
|
749
990
|
// Add color stops
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
991
|
+
addColorStops(gradient, parts);
|
|
992
|
+
return gradient;
|
|
993
|
+
}
|
|
994
|
+
else if (gradientStr.startsWith("conic-gradient")) {
|
|
995
|
+
const match = gradientStr.match(/conic-gradient\((.*)\)/);
|
|
996
|
+
if (!match)
|
|
997
|
+
return gradientStr;
|
|
998
|
+
const createConicGradient = ctx.createConicGradient;
|
|
999
|
+
if (!createConicGradient)
|
|
1000
|
+
return firstGradientColor(match[1]) ?? gradientStr;
|
|
1001
|
+
const centerX = x + width / 2;
|
|
1002
|
+
const centerY = y + height / 2;
|
|
1003
|
+
const gradient = createConicGradient.call(ctx, 0, centerX, centerY);
|
|
1004
|
+
addColorStops(gradient, splitCssArgs(match[1]));
|
|
754
1005
|
return gradient;
|
|
755
1006
|
}
|
|
756
1007
|
return gradientStr;
|
|
757
1008
|
}
|
|
1009
|
+
function splitCssArgs(input) {
|
|
1010
|
+
const parts = [];
|
|
1011
|
+
let depth = 0;
|
|
1012
|
+
let start = 0;
|
|
1013
|
+
for (let i = 0; i < input.length; i++) {
|
|
1014
|
+
const ch = input[i];
|
|
1015
|
+
if (ch === "(")
|
|
1016
|
+
depth++;
|
|
1017
|
+
else if (ch === ")")
|
|
1018
|
+
depth = Math.max(0, depth - 1);
|
|
1019
|
+
else if (ch === "," && depth === 0) {
|
|
1020
|
+
const part = input.slice(start, i).trim();
|
|
1021
|
+
if (part)
|
|
1022
|
+
parts.push(part);
|
|
1023
|
+
start = i + 1;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
const tail = input.slice(start).trim();
|
|
1027
|
+
if (tail)
|
|
1028
|
+
parts.push(tail);
|
|
1029
|
+
return parts;
|
|
1030
|
+
}
|
|
1031
|
+
function parseCssGradientAngle(direction) {
|
|
1032
|
+
const trimmed = direction.trim().toLowerCase();
|
|
1033
|
+
const deg = trimmed.match(/^(-?(?:\d+|\d*\.\d+))deg$/);
|
|
1034
|
+
if (deg)
|
|
1035
|
+
return Number(deg[1]);
|
|
1036
|
+
if (!trimmed.startsWith("to "))
|
|
1037
|
+
return null;
|
|
1038
|
+
const words = new Set(trimmed.slice(3).split(/\s+/).filter(Boolean));
|
|
1039
|
+
if (words.has("top") && words.has("right"))
|
|
1040
|
+
return 45;
|
|
1041
|
+
if (words.has("right") && words.has("bottom"))
|
|
1042
|
+
return 135;
|
|
1043
|
+
if (words.has("bottom") && words.has("left"))
|
|
1044
|
+
return 225;
|
|
1045
|
+
if (words.has("left") && words.has("top"))
|
|
1046
|
+
return 315;
|
|
1047
|
+
if (words.has("top"))
|
|
1048
|
+
return 0;
|
|
1049
|
+
if (words.has("right"))
|
|
1050
|
+
return 90;
|
|
1051
|
+
if (words.has("bottom"))
|
|
1052
|
+
return 180;
|
|
1053
|
+
if (words.has("left"))
|
|
1054
|
+
return 270;
|
|
1055
|
+
return null;
|
|
1056
|
+
}
|
|
1057
|
+
function gradientLineForAngle(cssDegrees, x, y, width, height) {
|
|
1058
|
+
const radians = (cssDegrees * Math.PI) / 180;
|
|
1059
|
+
const cx = x + width / 2;
|
|
1060
|
+
const cy = y + height / 2;
|
|
1061
|
+
const half = Math.sqrt(width * width + height * height) / 2;
|
|
1062
|
+
const dx = Math.sin(radians) * half;
|
|
1063
|
+
const dy = -Math.cos(radians) * half;
|
|
1064
|
+
return {
|
|
1065
|
+
x0: cx - dx,
|
|
1066
|
+
y0: cy - dy,
|
|
1067
|
+
x1: cx + dx,
|
|
1068
|
+
y1: cy + dy,
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function parseColorStop(raw, fallbackStop) {
|
|
1072
|
+
const trimmed = raw.trim();
|
|
1073
|
+
const match = trimmed.match(/^(.*\S)\s+(-?(?:\d+|\d*\.\d+)%)$/);
|
|
1074
|
+
if (!match)
|
|
1075
|
+
return { color: trimmed, stop: fallbackStop };
|
|
1076
|
+
return {
|
|
1077
|
+
color: match[1].trim(),
|
|
1078
|
+
stop: Math.max(0, Math.min(1, parseFloat(match[2]) / 100)),
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
function addColorStops(gradient, rawStops) {
|
|
1082
|
+
const stops = rawStops.filter(Boolean);
|
|
1083
|
+
if (stops.length === 0)
|
|
1084
|
+
return;
|
|
1085
|
+
if (stops.length === 1) {
|
|
1086
|
+
const parsed = parseColorStop(stops[0], 0);
|
|
1087
|
+
gradient.addColorStop(0, parsed.color);
|
|
1088
|
+
gradient.addColorStop(1, parsed.color);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
stops.forEach((raw, i) => {
|
|
1092
|
+
const fallback = i / (stops.length - 1);
|
|
1093
|
+
const { color, stop } = parseColorStop(raw, fallback);
|
|
1094
|
+
gradient.addColorStop(stop, color);
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function firstGradientColor(input) {
|
|
1098
|
+
const first = splitCssArgs(input)[0];
|
|
1099
|
+
if (!first)
|
|
1100
|
+
return undefined;
|
|
1101
|
+
return parseColorStop(first, 0).color;
|
|
1102
|
+
}
|
|
758
1103
|
/**
|
|
759
1104
|
* Apply CSS-like transforms to canvas context
|
|
760
1105
|
* Supports:
|
|
@@ -997,7 +1342,7 @@ function paintSlider(ctx, node) {
|
|
|
997
1342
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
998
1343
|
// Support gradient fills
|
|
999
1344
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1000
|
-
ctx.fillStyle =
|
|
1345
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
|
|
1001
1346
|
}
|
|
1002
1347
|
else {
|
|
1003
1348
|
ctx.fillStyle = fillColor;
|
|
@@ -1043,7 +1388,7 @@ function paintProgress(ctx, node) {
|
|
|
1043
1388
|
// Support gradient fills
|
|
1044
1389
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
1045
1390
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1046
|
-
ctx.fillStyle =
|
|
1391
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
|
|
1047
1392
|
}
|
|
1048
1393
|
else {
|
|
1049
1394
|
ctx.fillStyle = fillColor;
|
|
@@ -1099,18 +1444,13 @@ function paintCard(ctx, node) {
|
|
|
1099
1444
|
const y = layout.y;
|
|
1100
1445
|
const width = layout.width;
|
|
1101
1446
|
const height = layout.height;
|
|
1102
|
-
const radius = layout.border.radius;
|
|
1447
|
+
const radius = layout.border.radius || 8;
|
|
1103
1448
|
// Default card shadow
|
|
1104
1449
|
const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
|
|
1105
1450
|
applyShadow(ctx, shadow);
|
|
1106
1451
|
// Background
|
|
1107
|
-
const backgroundColor = props
|
|
1108
|
-
|
|
1109
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
1110
|
-
}
|
|
1111
|
-
else {
|
|
1112
|
-
ctx.fillStyle = backgroundColor;
|
|
1113
|
-
}
|
|
1452
|
+
const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
|
|
1453
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
|
|
1114
1454
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1115
1455
|
ctx.fill();
|
|
1116
1456
|
// Reset shadow
|
|
@@ -1146,8 +1486,8 @@ function paintBadge(ctx, node) {
|
|
|
1146
1486
|
ctx.fillStyle = backgroundColor;
|
|
1147
1487
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1148
1488
|
ctx.fill();
|
|
1149
|
-
//
|
|
1150
|
-
const text = String(props[0]
|
|
1489
|
+
// Text content
|
|
1490
|
+
const text = String(props[0] || props.text || "");
|
|
1151
1491
|
if (text) {
|
|
1152
1492
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1153
1493
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1177,8 +1517,8 @@ function paintAvatar(ctx, node) {
|
|
|
1177
1517
|
ctx.beginPath();
|
|
1178
1518
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1179
1519
|
ctx.fill();
|
|
1180
|
-
//
|
|
1181
|
-
const text = String(props[0]
|
|
1520
|
+
// Text initials if provided
|
|
1521
|
+
const text = String(props[0] || props.text || props.initials || "");
|
|
1182
1522
|
if (text) {
|
|
1183
1523
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1184
1524
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1293,8 +1633,7 @@ function paintIcon(ctx, node) {
|
|
|
1293
1633
|
function paintLink(ctx, node) {
|
|
1294
1634
|
const layout = node.layout;
|
|
1295
1635
|
const props = node.props;
|
|
1296
|
-
|
|
1297
|
-
const text = String(props[0] ?? props.text ?? "");
|
|
1636
|
+
const text = String(props[0] || props.text || "");
|
|
1298
1637
|
const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
|
|
1299
1638
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
1300
1639
|
const fontWeight = props.fontWeight || "normal";
|