@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/paint.ts
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
* Drawing virtual nodes to canvas
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type { VirtualNode, PainterFunction } from "./types.js";
|
|
8
|
-
import { renderText } from "./text.js";
|
|
7
|
+
import type { VirtualNode, PainterFunction, Rectangle } from "./types.js";
|
|
8
|
+
import { measureText, renderText } from "./text.js";
|
|
9
9
|
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
10
10
|
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
11
11
|
import type { SelectionManager } from "./selection.js";
|
|
12
|
-
import {
|
|
12
|
+
import type { TextEditController } from "./editing.js";
|
|
13
|
+
import {
|
|
14
|
+
SELECTION_HIGHLIGHT_COLOR,
|
|
15
|
+
offsetToCaretRect,
|
|
16
|
+
rangeToRects,
|
|
17
|
+
} from "./text-geometry.js";
|
|
18
|
+
import { cssLengthToPx } from "./utils.js";
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Module-level reference to the active SelectionManager so paintText
|
|
@@ -20,6 +26,16 @@ export function setSelectionManager(mgr: SelectionManager | null): void {
|
|
|
20
26
|
activeSelectionManager = mgr;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Module-level reference to the active TextEditController so paintInput can
|
|
31
|
+
* render the edited value, selection highlight, composition underline, and
|
|
32
|
+
* blinking caret. Same late-binding pattern as the SelectionManager hook.
|
|
33
|
+
*/
|
|
34
|
+
let activeTextEditor: TextEditController | null = null;
|
|
35
|
+
export function setTextEditor(editor: TextEditController | null): void {
|
|
36
|
+
activeTextEditor = editor;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
/**
|
|
24
40
|
* Custom painters registry
|
|
25
41
|
*/
|
|
@@ -33,23 +49,170 @@ export function registerPainter(type: string, painter: PainterFunction): void {
|
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
|
36
|
-
*
|
|
52
|
+
* Per-font character advance widths, used by the manual letter-spacing path
|
|
53
|
+
* so each glyph is shaped once per font instead of once per frame.
|
|
37
54
|
*/
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const charAdvanceCache = new Map<string, Map<string, number>>();
|
|
56
|
+
const MAX_CHAR_ADVANCE_FONTS = 32;
|
|
40
57
|
|
|
41
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Clear cached per-character advances. Must be called when web fonts finish
|
|
60
|
+
* loading: the cache is keyed by CSS font string, which is identical before
|
|
61
|
+
* and after a webfont replaces its fallback, so advances measured against
|
|
62
|
+
* the fallback font would otherwise poison letter-spaced rendering
|
|
63
|
+
* permanently.
|
|
64
|
+
*/
|
|
65
|
+
export function clearCharAdvanceCache(): void {
|
|
66
|
+
charAdvanceCache.clear();
|
|
67
|
+
}
|
|
42
68
|
|
|
43
|
-
|
|
44
|
-
|
|
69
|
+
function charAdvance(ctx: CanvasRenderingContext2D, font: string, ch: string): number {
|
|
70
|
+
let perFont = charAdvanceCache.get(font);
|
|
71
|
+
if (!perFont) {
|
|
72
|
+
perFont = new Map();
|
|
73
|
+
charAdvanceCache.set(font, perFont);
|
|
74
|
+
evictMap(charAdvanceCache, MAX_CHAR_ADVANCE_FONTS);
|
|
75
|
+
}
|
|
76
|
+
let width = perFont.get(ch);
|
|
77
|
+
if (width === undefined) {
|
|
78
|
+
width = ctx.measureText(ch).width;
|
|
79
|
+
perFont.set(ch, width);
|
|
80
|
+
}
|
|
81
|
+
return width;
|
|
82
|
+
}
|
|
45
83
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Component types whose painters set sticky canvas state (textAlign,
|
|
86
|
+
* textBaseline, lineCap, shadows, …) that must not leak to siblings — they
|
|
87
|
+
* need a scoping save/restore pair. Containers, text, and spacers (and
|
|
88
|
+
* every unknown type, which routes to paintContainer) only touch state each
|
|
89
|
+
* draw call re-sets before use, so they paint without one.
|
|
90
|
+
*/
|
|
91
|
+
const STATE_UNSAFE_TYPES = new Set([
|
|
92
|
+
"button",
|
|
93
|
+
"input",
|
|
94
|
+
"textarea",
|
|
95
|
+
"image",
|
|
96
|
+
"divider",
|
|
97
|
+
"separator",
|
|
98
|
+
"checkbox",
|
|
99
|
+
"radio",
|
|
100
|
+
"switch",
|
|
101
|
+
"toggle",
|
|
102
|
+
"slider",
|
|
103
|
+
"progress",
|
|
104
|
+
"progressbar",
|
|
105
|
+
"spinner",
|
|
106
|
+
"loading",
|
|
107
|
+
"card",
|
|
108
|
+
"badge",
|
|
109
|
+
"avatar",
|
|
110
|
+
"icon",
|
|
111
|
+
"link",
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
/** Whether any transform-related prop is present on the node. */
|
|
115
|
+
function hasTransformProps(props: Record<string, any>): boolean {
|
|
116
|
+
return (
|
|
117
|
+
props.transform !== undefined ||
|
|
118
|
+
props.translateX !== undefined ||
|
|
119
|
+
props.translateY !== undefined ||
|
|
120
|
+
props.rotate !== undefined ||
|
|
121
|
+
props.scale !== undefined ||
|
|
122
|
+
props.scaleX !== undefined ||
|
|
123
|
+
props.scaleY !== undefined ||
|
|
124
|
+
props.skew !== undefined ||
|
|
125
|
+
props.skewX !== undefined ||
|
|
126
|
+
props.skewY !== undefined
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Extra pixels a node's painting may reach beyond its layout box. */
|
|
131
|
+
const CULL_SLACK = 8;
|
|
132
|
+
|
|
133
|
+
/** How far a shadow can bleed outside the box it is attached to. */
|
|
134
|
+
function shadowExtent(shadow: any): number {
|
|
135
|
+
if (typeof shadow === "string") {
|
|
136
|
+
const parts = shadow.trim().split(/\s+/);
|
|
137
|
+
const ox = Math.abs(parseFloat(parts[0])) || 0;
|
|
138
|
+
const oy = Math.abs(parseFloat(parts[1])) || 0;
|
|
139
|
+
const blur = Math.abs(parseFloat(parts[2])) || 0;
|
|
140
|
+
return Math.max(ox, oy) + blur;
|
|
141
|
+
}
|
|
142
|
+
if (shadow && typeof shadow === "object") {
|
|
143
|
+
return (
|
|
144
|
+
Math.max(Math.abs(shadow.offsetX || 0), Math.abs(shadow.offsetY || 0)) +
|
|
145
|
+
Math.abs(shadow.blur || 0)
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the whole subtree rooted at `node` can be skipped when repainting
|
|
153
|
+
* only `cull`. Conservative: nodes with transforms or custom painters are
|
|
154
|
+
* never culled (they can draw anywhere), the box is expanded by shadow
|
|
155
|
+
* bleed + slack, and a node with children is only culled when it clips them.
|
|
156
|
+
*/
|
|
157
|
+
function canCullSubtree(node: VirtualNode, cull: Rectangle): boolean {
|
|
158
|
+
const layout = node.layout!;
|
|
159
|
+
if (hasTransformProps(node.props)) return false;
|
|
160
|
+
if (customPainters.has(node.type.toLowerCase())) return false;
|
|
161
|
+
|
|
162
|
+
const shadow = node.props.shadow || node.props.boxShadow || node.props.textShadow;
|
|
163
|
+
const margin = CULL_SLACK + (shadow ? shadowExtent(shadow) : 0);
|
|
164
|
+
if (
|
|
165
|
+
layout.x - margin < cull.x + cull.width &&
|
|
166
|
+
layout.x + layout.width + margin > cull.x &&
|
|
167
|
+
layout.y - margin < cull.y + cull.height &&
|
|
168
|
+
layout.y + layout.height + margin > cull.y
|
|
169
|
+
) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (node.children.length === 0) return true;
|
|
174
|
+
const overflow = node.props.overflow;
|
|
175
|
+
return overflow === "hidden" || overflow === "scroll" || overflow === "auto";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Paint a virtual node and its children.
|
|
180
|
+
*
|
|
181
|
+
* `cull` (optional) is the dirty region being repainted — subtrees that
|
|
182
|
+
* provably cannot reach it are skipped entirely.
|
|
183
|
+
*/
|
|
184
|
+
export function paintNode(
|
|
185
|
+
ctx: CanvasRenderingContext2D,
|
|
186
|
+
node: VirtualNode,
|
|
187
|
+
cull?: Rectangle | null,
|
|
188
|
+
): void {
|
|
189
|
+
if (!node.visible || !node.layout) return;
|
|
190
|
+
if (cull && canCullSubtree(node, cull)) return;
|
|
191
|
+
|
|
192
|
+
const type = node.type.toLowerCase();
|
|
193
|
+
const customPainter = customPainters.get(type);
|
|
194
|
+
const needsTransform = hasTransformProps(node.props);
|
|
195
|
+
const needsSave =
|
|
196
|
+
customPainter !== undefined ||
|
|
197
|
+
needsTransform ||
|
|
198
|
+
node.opacity < 1 ||
|
|
199
|
+
STATE_UNSAFE_TYPES.has(type);
|
|
200
|
+
|
|
201
|
+
if (needsSave) {
|
|
202
|
+
ctx.save();
|
|
203
|
+
|
|
204
|
+
// Apply transforms
|
|
205
|
+
if (needsTransform) {
|
|
206
|
+
applyTransforms(ctx, node);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Apply opacity
|
|
210
|
+
if (node.opacity < 1) {
|
|
211
|
+
ctx.globalAlpha = node.opacity;
|
|
212
|
+
}
|
|
49
213
|
}
|
|
50
214
|
|
|
51
215
|
// Check for custom painter
|
|
52
|
-
const customPainter = customPainters.get(node.type.toLowerCase());
|
|
53
216
|
if (customPainter) {
|
|
54
217
|
customPainter(ctx, node);
|
|
55
218
|
ctx.restore();
|
|
@@ -57,7 +220,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
57
220
|
}
|
|
58
221
|
|
|
59
222
|
// Default painting based on type
|
|
60
|
-
switch (
|
|
223
|
+
switch (type) {
|
|
61
224
|
case "column":
|
|
62
225
|
case "row":
|
|
63
226
|
case "stack":
|
|
@@ -70,6 +233,7 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
70
233
|
paintButton(ctx, node);
|
|
71
234
|
break;
|
|
72
235
|
case "input":
|
|
236
|
+
case "textarea":
|
|
73
237
|
paintInput(ctx, node);
|
|
74
238
|
break;
|
|
75
239
|
case "image":
|
|
@@ -127,14 +291,26 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
127
291
|
paintContainer(ctx, node);
|
|
128
292
|
}
|
|
129
293
|
|
|
130
|
-
|
|
294
|
+
if (needsSave) {
|
|
295
|
+
ctx.restore();
|
|
296
|
+
}
|
|
131
297
|
|
|
132
298
|
// Apply scroll translation for scrollable containers
|
|
133
299
|
const ss = node.scrollState;
|
|
300
|
+
let childCull = cull ?? null;
|
|
134
301
|
if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
|
|
135
302
|
ctx.save();
|
|
136
303
|
ctx.translate(-ss.scrollX, -ss.scrollY);
|
|
137
304
|
(node as any)._scrollRestore = true;
|
|
305
|
+
// Children paint in scrolled space — shift the cull region to match.
|
|
306
|
+
if (childCull) {
|
|
307
|
+
childCull = {
|
|
308
|
+
x: childCull.x + ss.scrollX,
|
|
309
|
+
y: childCull.y + ss.scrollY,
|
|
310
|
+
width: childCull.width,
|
|
311
|
+
height: childCull.height,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
138
314
|
}
|
|
139
315
|
|
|
140
316
|
// Paint children -- use windowed rendering for large scrollable lists
|
|
@@ -158,14 +334,21 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
158
334
|
// tree (Story uses this for its close button) gets covered by the
|
|
159
335
|
// following in-flow Image. Push absolute kids to the end so they land
|
|
160
336
|
// on top — same effect as `z-index: auto` painting absolute after flow.
|
|
161
|
-
|
|
162
|
-
const overlayKids: VirtualNode[] = [];
|
|
337
|
+
let hasOverlays = false;
|
|
163
338
|
for (const child of childrenToPaint) {
|
|
164
|
-
if (child.props.position === "absolute")
|
|
165
|
-
|
|
339
|
+
if (child.props.position === "absolute") {
|
|
340
|
+
hasOverlays = true;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
paintNode(ctx, child, childCull);
|
|
344
|
+
}
|
|
345
|
+
if (hasOverlays) {
|
|
346
|
+
for (const child of childrenToPaint) {
|
|
347
|
+
if (child.props.position === "absolute") {
|
|
348
|
+
paintNode(ctx, child, childCull);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
166
351
|
}
|
|
167
|
-
for (const child of flowKids) paintNode(ctx, child);
|
|
168
|
-
for (const child of overlayKids) paintNode(ctx, child);
|
|
169
352
|
|
|
170
353
|
// Undo scroll translation before drawing scrollbars
|
|
171
354
|
if ((node as any)._scrollRestore) {
|
|
@@ -205,14 +388,9 @@ function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void
|
|
|
205
388
|
}
|
|
206
389
|
|
|
207
390
|
// Draw background
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
|
|
212
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
213
|
-
} else {
|
|
214
|
-
ctx.fillStyle = backgroundColor;
|
|
215
|
-
}
|
|
391
|
+
const background = resolveBackgroundPaint(props);
|
|
392
|
+
if (background) {
|
|
393
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, background, x, y, width, height);
|
|
216
394
|
|
|
217
395
|
if (radius > 0) {
|
|
218
396
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
@@ -271,14 +449,13 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
271
449
|
const layout = node.layout!;
|
|
272
450
|
const props = node.props;
|
|
273
451
|
|
|
274
|
-
|
|
275
|
-
let text = String(props[0] ?? props.text ?? "");
|
|
452
|
+
let text = String(props[0] || props.text || "");
|
|
276
453
|
const color = props.color || "#000000";
|
|
277
454
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
278
455
|
const fontWeight = props.fontWeight || "normal";
|
|
279
456
|
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
280
457
|
const textAlign = props.textAlign || "left";
|
|
281
|
-
const lineHeight =
|
|
458
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
282
459
|
const textDecoration = props.textDecoration || "none";
|
|
283
460
|
const textTransform = props.textTransform || "none";
|
|
284
461
|
const letterSpacing = cssLengthToPx(props.letterSpacing) ?? 0;
|
|
@@ -305,14 +482,15 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
305
482
|
if (letterSpacing !== 0) {
|
|
306
483
|
// Manual letter spacing
|
|
307
484
|
ctx.fillStyle = color;
|
|
308
|
-
|
|
485
|
+
const font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
486
|
+
ctx.font = font;
|
|
309
487
|
ctx.textAlign = "left";
|
|
310
488
|
ctx.textBaseline = "top";
|
|
311
489
|
|
|
312
490
|
let currentX = x;
|
|
313
491
|
for (let i = 0; i < text.length; i++) {
|
|
314
492
|
ctx.fillText(text[i], currentX, y);
|
|
315
|
-
currentX += ctx
|
|
493
|
+
currentX += charAdvance(ctx, font, text[i]);
|
|
316
494
|
// Add letter spacing after each character except the last
|
|
317
495
|
if (i < text.length - 1) {
|
|
318
496
|
currentX += letterSpacing;
|
|
@@ -356,11 +534,18 @@ function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
356
534
|
textOverflow
|
|
357
535
|
);
|
|
358
536
|
|
|
359
|
-
// Text decoration
|
|
537
|
+
// Text decoration — reuse the wrapped metrics renderText just resolved
|
|
538
|
+
// (cache hit) instead of re-shaping the raw, unwrapped string.
|
|
360
539
|
if (textDecoration !== "none") {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
540
|
+
const metrics = measureText(
|
|
541
|
+
ctx,
|
|
542
|
+
text,
|
|
543
|
+
{ fontSize, fontWeight, fontFamily, lineHeight },
|
|
544
|
+
layout.contentWidth,
|
|
545
|
+
maxLines,
|
|
546
|
+
textOverflow,
|
|
547
|
+
);
|
|
548
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, metrics.width, fontSize);
|
|
364
549
|
}
|
|
365
550
|
}
|
|
366
551
|
|
|
@@ -384,7 +569,7 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
384
569
|
const y = layout.y;
|
|
385
570
|
const width = layout.width;
|
|
386
571
|
const height = layout.height;
|
|
387
|
-
const radius = layout.border.radius;
|
|
572
|
+
const radius = layout.border.radius || 4;
|
|
388
573
|
|
|
389
574
|
// Apply shadow if specified
|
|
390
575
|
const shadow = props.shadow || props.boxShadow;
|
|
@@ -403,16 +588,12 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
403
588
|
backgroundColor = props.hoverColor;
|
|
404
589
|
} else if (node.focused && props.focusColor !== undefined) {
|
|
405
590
|
backgroundColor = props.focusColor;
|
|
406
|
-
} else
|
|
407
|
-
backgroundColor = props
|
|
591
|
+
} else {
|
|
592
|
+
backgroundColor = resolveBackgroundPaint(props);
|
|
408
593
|
}
|
|
409
594
|
|
|
410
595
|
if (backgroundColor !== undefined) {
|
|
411
|
-
|
|
412
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
413
|
-
} else {
|
|
414
|
-
ctx.fillStyle = backgroundColor;
|
|
415
|
-
}
|
|
596
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
|
|
416
597
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
417
598
|
ctx.fill();
|
|
418
599
|
}
|
|
@@ -433,6 +614,18 @@ function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
433
614
|
ctx.stroke();
|
|
434
615
|
}
|
|
435
616
|
|
|
617
|
+
// Keyboard focus ring. Focus lives on the node's (unrendered) mirror
|
|
618
|
+
// element in the canvas fallback content, so the UA can't draw a ring at
|
|
619
|
+
// the painted box — paint one so keyboard users can see where they are.
|
|
620
|
+
if (node.focused) {
|
|
621
|
+
ctx.save();
|
|
622
|
+
ctx.strokeStyle = props.focusRingColor || "#007bff";
|
|
623
|
+
ctx.lineWidth = 2;
|
|
624
|
+
drawRoundedRect(ctx, x - 2, y - 2, width + 4, height + 4, radius + 2);
|
|
625
|
+
ctx.stroke();
|
|
626
|
+
ctx.restore();
|
|
627
|
+
}
|
|
628
|
+
|
|
436
629
|
// Paint children (typically Text)
|
|
437
630
|
for (const child of node.children) {
|
|
438
631
|
paintNode(ctx, child);
|
|
@@ -450,7 +643,7 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
450
643
|
const y = layout.y;
|
|
451
644
|
const width = layout.width;
|
|
452
645
|
const height = layout.height;
|
|
453
|
-
const radius = layout.border.radius;
|
|
646
|
+
const radius = layout.border.radius || 4;
|
|
454
647
|
|
|
455
648
|
// Background
|
|
456
649
|
ctx.fillStyle = props.backgroundColor || "#ffffff";
|
|
@@ -465,24 +658,76 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
465
658
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
466
659
|
ctx.stroke();
|
|
467
660
|
|
|
468
|
-
|
|
469
|
-
|
|
661
|
+
const isSingleLine = node.type.toLowerCase() === "input";
|
|
662
|
+
|
|
663
|
+
// While editing, the mirror element (via TextEditController) is the value
|
|
664
|
+
// source — paint stays ahead of the engine's SetProp echo.
|
|
665
|
+
const edit = activeTextEditor?.getStateFor(node) ?? null;
|
|
666
|
+
|
|
667
|
+
const value = edit ? edit.value : (props.value || "");
|
|
470
668
|
const placeholder = props.placeholder || "";
|
|
471
669
|
const text = value || placeholder;
|
|
472
670
|
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
473
671
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
672
|
+
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
673
|
+
const fontWeight = props.fontWeight || "normal";
|
|
674
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
675
|
+
const lineHeight = cssLengthToPx(props.lineHeight) ?? fontSize * 1.2;
|
|
676
|
+
|
|
677
|
+
if (!edit) {
|
|
678
|
+
if (text) {
|
|
679
|
+
renderText(
|
|
680
|
+
ctx,
|
|
681
|
+
text,
|
|
682
|
+
layout.x + layout.contentX,
|
|
683
|
+
layout.y + layout.contentY,
|
|
684
|
+
layout.contentWidth,
|
|
685
|
+
layout.contentHeight,
|
|
686
|
+
{
|
|
687
|
+
color: textColor,
|
|
688
|
+
fontSize,
|
|
689
|
+
fontWeight,
|
|
690
|
+
fontFamily,
|
|
691
|
+
textAlign: "left",
|
|
692
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
693
|
+
lineHeight,
|
|
694
|
+
}
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
479
699
|
|
|
700
|
+
// ---- Editing visuals: clip → selection highlight → text → composition
|
|
701
|
+
// ---- underline → caret. Geometry comes from the edit controller so
|
|
702
|
+
// ---- pointer mapping and painting share identical math (incl. scrollX).
|
|
703
|
+
ctx.save();
|
|
704
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
705
|
+
ctx.clip();
|
|
706
|
+
|
|
707
|
+
const g = activeTextEditor!.geometry(
|
|
708
|
+
layout.x + layout.contentX - edit.scrollX,
|
|
709
|
+
layout.y + layout.contentY,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
// Selection highlight behind the text
|
|
713
|
+
if (edit.selStart !== edit.selEnd) {
|
|
714
|
+
const from = Math.min(edit.selStart, edit.selEnd);
|
|
715
|
+
const to = Math.max(edit.selStart, edit.selEnd);
|
|
716
|
+
ctx.fillStyle = SELECTION_HIGHLIGHT_COLOR;
|
|
717
|
+
for (const r of rangeToRects(ctx, g, from, to)) {
|
|
718
|
+
ctx.fillRect(r.x, r.y, r.width, r.height);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
if (text) {
|
|
480
723
|
renderText(
|
|
481
724
|
ctx,
|
|
482
|
-
|
|
483
|
-
|
|
725
|
+
value || placeholder,
|
|
726
|
+
g.contentX,
|
|
484
727
|
layout.y + layout.contentY,
|
|
485
|
-
|
|
728
|
+
// Single-line inputs never wrap — the clip + scrollX pan handle
|
|
729
|
+
// overflow. Textareas wrap at content width like static paint.
|
|
730
|
+
isSingleLine ? Number.POSITIVE_INFINITY : layout.contentWidth,
|
|
486
731
|
layout.contentHeight,
|
|
487
732
|
{
|
|
488
733
|
color: textColor,
|
|
@@ -490,11 +735,34 @@ function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
490
735
|
fontWeight,
|
|
491
736
|
fontFamily,
|
|
492
737
|
textAlign: "left",
|
|
493
|
-
verticalAlign: "middle",
|
|
738
|
+
verticalAlign: isSingleLine ? "middle" : "top",
|
|
494
739
|
lineHeight,
|
|
495
740
|
}
|
|
496
741
|
);
|
|
497
742
|
}
|
|
743
|
+
|
|
744
|
+
// IME composition underline (dashed, under the composing range)
|
|
745
|
+
if (edit.compStart !== null && edit.compEnd !== null && edit.compEnd > edit.compStart) {
|
|
746
|
+
ctx.strokeStyle = props.color || "#000000";
|
|
747
|
+
ctx.lineWidth = 1;
|
|
748
|
+
ctx.setLineDash([2, 2]);
|
|
749
|
+
for (const r of rangeToRects(ctx, g, edit.compStart, edit.compEnd)) {
|
|
750
|
+
ctx.beginPath();
|
|
751
|
+
ctx.moveTo(r.x, r.y + r.height - 2);
|
|
752
|
+
ctx.lineTo(r.x + r.width, r.y + r.height - 2);
|
|
753
|
+
ctx.stroke();
|
|
754
|
+
}
|
|
755
|
+
ctx.setLineDash([]);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Caret (hidden while a range is selected, blinks via the edit timer)
|
|
759
|
+
if (edit.caretVisible && edit.selStart === edit.selEnd) {
|
|
760
|
+
const caret = offsetToCaretRect(ctx, g, edit.selEnd);
|
|
761
|
+
ctx.fillStyle = props.color || "#000000";
|
|
762
|
+
ctx.fillRect(caret.x, caret.y + 1, 1.5, caret.height - 2);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
ctx.restore();
|
|
498
766
|
}
|
|
499
767
|
|
|
500
768
|
/**
|
|
@@ -658,8 +926,10 @@ function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
658
926
|
imagePending.delete(src);
|
|
659
927
|
imageFailCache.delete(src);
|
|
660
928
|
evictMap(imageCache, MAX_IMAGE_CACHE_SIZE);
|
|
661
|
-
// Request a repaint so the loaded image is drawn
|
|
662
|
-
|
|
929
|
+
// Request a repaint so the loaded image is drawn. `layout: true`
|
|
930
|
+
// because the decoded intrinsic size can change the image's box
|
|
931
|
+
// (see getImageNaturalAspect in layout.ts).
|
|
932
|
+
canvas.dispatchEvent(new CustomEvent("hypen:redraw", { detail: { layout: true } }));
|
|
663
933
|
};
|
|
664
934
|
img.onerror = () => {
|
|
665
935
|
imageInFlight.delete(src);
|
|
@@ -725,14 +995,6 @@ function drawRoundedRect(
|
|
|
725
995
|
radius: number
|
|
726
996
|
): void {
|
|
727
997
|
if (radius <= 0 || width <= 0 || height <= 0) {
|
|
728
|
-
// beginPath is critical here — without it, this rect appends to the
|
|
729
|
-
// existing path and the next `ctx.fill()` re-fills every previously
|
|
730
|
-
// queued rect with the current fillStyle. That manifested as a
|
|
731
|
-
// calculator grid where every button painted in the last button's
|
|
732
|
-
// colour and only the last button's child text survived (each
|
|
733
|
-
// subsequent fill repainted over earlier children). Mirrors the
|
|
734
|
-
// beginPath() the rounded branch already does below.
|
|
735
|
-
ctx.beginPath();
|
|
736
998
|
ctx.rect(x, y, width, height);
|
|
737
999
|
return;
|
|
738
1000
|
}
|
|
@@ -783,6 +1045,62 @@ function applyShadow(ctx: CanvasRenderingContext2D, shadow: any): void {
|
|
|
783
1045
|
}
|
|
784
1046
|
}
|
|
785
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* Resolve DOM background applicator props into the CSS-like paint strings
|
|
1050
|
+
* Canvas understands. DOM applicators map `.linearGradient("...")` to
|
|
1051
|
+
* `background-image: linear-gradient(...)`; Canvas receives the original prop
|
|
1052
|
+
* and must do the same translation before painting.
|
|
1053
|
+
*/
|
|
1054
|
+
function resolveBackgroundPaint(props: Record<string, any>): string | undefined {
|
|
1055
|
+
if (props.linearGradient !== undefined) {
|
|
1056
|
+
return `linear-gradient(${props.linearGradient})`;
|
|
1057
|
+
}
|
|
1058
|
+
if (props.radialGradient !== undefined) {
|
|
1059
|
+
return `radial-gradient(${props.radialGradient})`;
|
|
1060
|
+
}
|
|
1061
|
+
if (props.conicGradient !== undefined) {
|
|
1062
|
+
return `conic-gradient(${props.conicGradient})`;
|
|
1063
|
+
}
|
|
1064
|
+
if (props.backgroundImage !== undefined) {
|
|
1065
|
+
return String(props.backgroundImage);
|
|
1066
|
+
}
|
|
1067
|
+
if (props.backgroundColor !== undefined) {
|
|
1068
|
+
return String(props.backgroundColor);
|
|
1069
|
+
}
|
|
1070
|
+
if (props.background !== undefined) {
|
|
1071
|
+
return String(props.background);
|
|
1072
|
+
}
|
|
1073
|
+
return undefined;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* Parsed gradients keyed by (source string, box geometry). CanvasGradient
|
|
1078
|
+
* objects are not bound to a specific context, so identical fills across
|
|
1079
|
+
* frames — the common case, since layout geometry is stable — skip the
|
|
1080
|
+
* regex parse and createLinearGradient/createRadialGradient entirely.
|
|
1081
|
+
*/
|
|
1082
|
+
const gradientCache = new Map<string, CanvasGradient | string>();
|
|
1083
|
+
const MAX_GRADIENT_CACHE_SIZE = 256;
|
|
1084
|
+
|
|
1085
|
+
function resolveCanvasPaint(
|
|
1086
|
+
ctx: CanvasRenderingContext2D,
|
|
1087
|
+
paint: string,
|
|
1088
|
+
x: number,
|
|
1089
|
+
y: number,
|
|
1090
|
+
width: number,
|
|
1091
|
+
height: number,
|
|
1092
|
+
): CanvasGradient | string {
|
|
1093
|
+
if (!paint.includes("gradient(")) return paint;
|
|
1094
|
+
const key = `${paint}|${x}|${y}|${width}|${height}`;
|
|
1095
|
+
let resolved = gradientCache.get(key);
|
|
1096
|
+
if (resolved === undefined) {
|
|
1097
|
+
resolved = parseGradient(ctx, paint, x, y, width, height);
|
|
1098
|
+
gradientCache.set(key, resolved);
|
|
1099
|
+
evictMap(gradientCache, MAX_GRADIENT_CACHE_SIZE);
|
|
1100
|
+
}
|
|
1101
|
+
return resolved;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
786
1104
|
/**
|
|
787
1105
|
* Parse gradient string and create canvas gradient
|
|
788
1106
|
* Supports:
|
|
@@ -803,7 +1121,7 @@ function parseGradient(
|
|
|
803
1121
|
const match = gradientStr.match(/linear-gradient\((.*)\)/);
|
|
804
1122
|
if (!match) return gradientStr;
|
|
805
1123
|
|
|
806
|
-
const parts = match[1]
|
|
1124
|
+
const parts = splitCssArgs(match[1]);
|
|
807
1125
|
|
|
808
1126
|
// Determine direction (default to bottom)
|
|
809
1127
|
let x0 = x, y0 = y, x1 = x, y1 = y + height;
|
|
@@ -813,29 +1131,20 @@ function parseGradient(
|
|
|
813
1131
|
colorStart = 1;
|
|
814
1132
|
const direction = parts[0];
|
|
815
1133
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
x1 =
|
|
822
|
-
|
|
823
|
-
y1 = y;
|
|
824
|
-
} else if (direction.includes("to top") || direction === "0deg") {
|
|
825
|
-
y0 = y + height;
|
|
826
|
-
y1 = y;
|
|
1134
|
+
const angle = parseCssGradientAngle(direction);
|
|
1135
|
+
if (angle !== null) {
|
|
1136
|
+
const line = gradientLineForAngle(angle, x, y, width, height);
|
|
1137
|
+
x0 = line.x0;
|
|
1138
|
+
y0 = line.y0;
|
|
1139
|
+
x1 = line.x1;
|
|
1140
|
+
y1 = line.y1;
|
|
827
1141
|
}
|
|
828
|
-
// Default is "to bottom" which we already set
|
|
829
1142
|
}
|
|
830
1143
|
|
|
831
1144
|
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
832
1145
|
|
|
833
1146
|
// Add color stops
|
|
834
|
-
|
|
835
|
-
colors.forEach((color, i) => {
|
|
836
|
-
const stop = i / (colors.length - 1);
|
|
837
|
-
gradient.addColorStop(stop, color.trim());
|
|
838
|
-
});
|
|
1147
|
+
addColorStops(gradient, parts.slice(colorStart));
|
|
839
1148
|
|
|
840
1149
|
return gradient;
|
|
841
1150
|
} else if (gradientStr.startsWith("radial-gradient")) {
|
|
@@ -843,7 +1152,7 @@ function parseGradient(
|
|
|
843
1152
|
const match = gradientStr.match(/radial-gradient\((.*)\)/);
|
|
844
1153
|
if (!match) return gradientStr;
|
|
845
1154
|
|
|
846
|
-
const parts = match[1]
|
|
1155
|
+
const parts = splitCssArgs(match[1]);
|
|
847
1156
|
|
|
848
1157
|
// Create radial gradient from center
|
|
849
1158
|
const centerX = x + width / 2;
|
|
@@ -853,17 +1162,116 @@ function parseGradient(
|
|
|
853
1162
|
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
|
854
1163
|
|
|
855
1164
|
// Add color stops
|
|
856
|
-
|
|
857
|
-
const stop = i / (parts.length - 1);
|
|
858
|
-
gradient.addColorStop(stop, color.trim());
|
|
859
|
-
});
|
|
1165
|
+
addColorStops(gradient, parts);
|
|
860
1166
|
|
|
1167
|
+
return gradient;
|
|
1168
|
+
} else if (gradientStr.startsWith("conic-gradient")) {
|
|
1169
|
+
const match = gradientStr.match(/conic-gradient\((.*)\)/);
|
|
1170
|
+
if (!match) return gradientStr;
|
|
1171
|
+
const createConicGradient = (ctx as CanvasRenderingContext2D & {
|
|
1172
|
+
createConicGradient?: (startAngle: number, x: number, y: number) => CanvasGradient;
|
|
1173
|
+
}).createConicGradient;
|
|
1174
|
+
if (!createConicGradient) return firstGradientColor(match[1]) ?? gradientStr;
|
|
1175
|
+
const centerX = x + width / 2;
|
|
1176
|
+
const centerY = y + height / 2;
|
|
1177
|
+
const gradient = createConicGradient.call(ctx, 0, centerX, centerY);
|
|
1178
|
+
addColorStops(gradient, splitCssArgs(match[1]));
|
|
861
1179
|
return gradient;
|
|
862
1180
|
}
|
|
863
1181
|
|
|
864
1182
|
return gradientStr;
|
|
865
1183
|
}
|
|
866
1184
|
|
|
1185
|
+
function splitCssArgs(input: string): string[] {
|
|
1186
|
+
const parts: string[] = [];
|
|
1187
|
+
let depth = 0;
|
|
1188
|
+
let start = 0;
|
|
1189
|
+
for (let i = 0; i < input.length; i++) {
|
|
1190
|
+
const ch = input[i];
|
|
1191
|
+
if (ch === "(") depth++;
|
|
1192
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
1193
|
+
else if (ch === "," && depth === 0) {
|
|
1194
|
+
const part = input.slice(start, i).trim();
|
|
1195
|
+
if (part) parts.push(part);
|
|
1196
|
+
start = i + 1;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
const tail = input.slice(start).trim();
|
|
1200
|
+
if (tail) parts.push(tail);
|
|
1201
|
+
return parts;
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function parseCssGradientAngle(direction: string): number | null {
|
|
1205
|
+
const trimmed = direction.trim().toLowerCase();
|
|
1206
|
+
const deg = trimmed.match(/^(-?(?:\d+|\d*\.\d+))deg$/);
|
|
1207
|
+
if (deg) return Number(deg[1]);
|
|
1208
|
+
if (!trimmed.startsWith("to ")) return null;
|
|
1209
|
+
|
|
1210
|
+
const words = new Set(trimmed.slice(3).split(/\s+/).filter(Boolean));
|
|
1211
|
+
if (words.has("top") && words.has("right")) return 45;
|
|
1212
|
+
if (words.has("right") && words.has("bottom")) return 135;
|
|
1213
|
+
if (words.has("bottom") && words.has("left")) return 225;
|
|
1214
|
+
if (words.has("left") && words.has("top")) return 315;
|
|
1215
|
+
if (words.has("top")) return 0;
|
|
1216
|
+
if (words.has("right")) return 90;
|
|
1217
|
+
if (words.has("bottom")) return 180;
|
|
1218
|
+
if (words.has("left")) return 270;
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function gradientLineForAngle(
|
|
1223
|
+
cssDegrees: number,
|
|
1224
|
+
x: number,
|
|
1225
|
+
y: number,
|
|
1226
|
+
width: number,
|
|
1227
|
+
height: number,
|
|
1228
|
+
): { x0: number; y0: number; x1: number; y1: number } {
|
|
1229
|
+
const radians = (cssDegrees * Math.PI) / 180;
|
|
1230
|
+
const cx = x + width / 2;
|
|
1231
|
+
const cy = y + height / 2;
|
|
1232
|
+
const half = Math.sqrt(width * width + height * height) / 2;
|
|
1233
|
+
const dx = Math.sin(radians) * half;
|
|
1234
|
+
const dy = -Math.cos(radians) * half;
|
|
1235
|
+
return {
|
|
1236
|
+
x0: cx - dx,
|
|
1237
|
+
y0: cy - dy,
|
|
1238
|
+
x1: cx + dx,
|
|
1239
|
+
y1: cy + dy,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function parseColorStop(raw: string, fallbackStop: number): { color: string; stop: number } {
|
|
1244
|
+
const trimmed = raw.trim();
|
|
1245
|
+
const match = trimmed.match(/^(.*\S)\s+(-?(?:\d+|\d*\.\d+)%)$/);
|
|
1246
|
+
if (!match) return { color: trimmed, stop: fallbackStop };
|
|
1247
|
+
return {
|
|
1248
|
+
color: match[1].trim(),
|
|
1249
|
+
stop: Math.max(0, Math.min(1, parseFloat(match[2]) / 100)),
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function addColorStops(gradient: CanvasGradient, rawStops: string[]): void {
|
|
1254
|
+
const stops = rawStops.filter(Boolean);
|
|
1255
|
+
if (stops.length === 0) return;
|
|
1256
|
+
if (stops.length === 1) {
|
|
1257
|
+
const parsed = parseColorStop(stops[0], 0);
|
|
1258
|
+
gradient.addColorStop(0, parsed.color);
|
|
1259
|
+
gradient.addColorStop(1, parsed.color);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
stops.forEach((raw, i) => {
|
|
1263
|
+
const fallback = i / (stops.length - 1);
|
|
1264
|
+
const { color, stop } = parseColorStop(raw, fallback);
|
|
1265
|
+
gradient.addColorStop(stop, color);
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
function firstGradientColor(input: string): string | undefined {
|
|
1270
|
+
const first = splitCssArgs(input)[0];
|
|
1271
|
+
if (!first) return undefined;
|
|
1272
|
+
return parseColorStop(first, 0).color;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
867
1275
|
/**
|
|
868
1276
|
* Apply CSS-like transforms to canvas context
|
|
869
1277
|
* Supports:
|
|
@@ -1157,7 +1565,7 @@ function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1157
1565
|
|
|
1158
1566
|
// Support gradient fills
|
|
1159
1567
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1160
|
-
ctx.fillStyle =
|
|
1568
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, trackY, fillWidth, trackHeight);
|
|
1161
1569
|
} else {
|
|
1162
1570
|
ctx.fillStyle = fillColor;
|
|
1163
1571
|
}
|
|
@@ -1212,7 +1620,7 @@ function paintProgress(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1212
1620
|
// Support gradient fills
|
|
1213
1621
|
const fillColor = props.fillColor || props.color || "#007bff";
|
|
1214
1622
|
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
1215
|
-
ctx.fillStyle =
|
|
1623
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, fillColor, x, y, fillWidth, height);
|
|
1216
1624
|
} else {
|
|
1217
1625
|
ctx.fillStyle = fillColor;
|
|
1218
1626
|
}
|
|
@@ -1277,19 +1685,15 @@ function paintCard(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1277
1685
|
const y = layout.y;
|
|
1278
1686
|
const width = layout.width;
|
|
1279
1687
|
const height = layout.height;
|
|
1280
|
-
const radius = layout.border.radius;
|
|
1688
|
+
const radius = layout.border.radius || 8;
|
|
1281
1689
|
|
|
1282
1690
|
// Default card shadow
|
|
1283
1691
|
const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
|
|
1284
1692
|
applyShadow(ctx, shadow);
|
|
1285
1693
|
|
|
1286
1694
|
// Background
|
|
1287
|
-
const backgroundColor = props
|
|
1288
|
-
|
|
1289
|
-
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
1290
|
-
} else {
|
|
1291
|
-
ctx.fillStyle = backgroundColor;
|
|
1292
|
-
}
|
|
1695
|
+
const backgroundColor = resolveBackgroundPaint(props) || "#ffffff";
|
|
1696
|
+
ctx.fillStyle = resolveCanvasPaint(ctx, backgroundColor, x, y, width, height);
|
|
1293
1697
|
|
|
1294
1698
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1295
1699
|
ctx.fill();
|
|
@@ -1333,8 +1737,8 @@ function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1333
1737
|
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1334
1738
|
ctx.fill();
|
|
1335
1739
|
|
|
1336
|
-
//
|
|
1337
|
-
const text = String(props[0]
|
|
1740
|
+
// Text content
|
|
1741
|
+
const text = String(props[0] || props.text || "");
|
|
1338
1742
|
if (text) {
|
|
1339
1743
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1340
1744
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1369,8 +1773,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1369
1773
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1370
1774
|
ctx.fill();
|
|
1371
1775
|
|
|
1372
|
-
//
|
|
1373
|
-
const text = String(props[0]
|
|
1776
|
+
// Text initials if provided
|
|
1777
|
+
const text = String(props[0] || props.text || props.initials || "");
|
|
1374
1778
|
if (text) {
|
|
1375
1779
|
ctx.fillStyle = props.color || "#ffffff";
|
|
1376
1780
|
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
|
|
@@ -1510,8 +1914,7 @@ function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1510
1914
|
const layout = node.layout!;
|
|
1511
1915
|
const props = node.props;
|
|
1512
1916
|
|
|
1513
|
-
|
|
1514
|
-
const text = String(props[0] ?? props.text ?? "");
|
|
1917
|
+
const text = String(props[0] || props.text || "");
|
|
1515
1918
|
const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
|
|
1516
1919
|
const fontSize = cssLengthToPx(props.fontSize) ?? 16;
|
|
1517
1920
|
const fontWeight = props.fontWeight || "normal";
|
|
@@ -1641,6 +2044,3 @@ function drawStar(
|
|
|
1641
2044
|
|
|
1642
2045
|
|
|
1643
2046
|
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|