@hypen-space/web 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-2s02mkzs.js +32 -0
- package/dist/chunk-2s02mkzs.js.map +9 -0
- package/dist/src/canvas/accessibility.js +152 -0
- package/dist/src/canvas/accessibility.js.map +10 -0
- package/dist/src/canvas/events.js +198 -0
- package/dist/src/canvas/events.js.map +10 -0
- package/dist/src/canvas/index.js +28 -0
- package/dist/src/canvas/index.js.map +9 -0
- package/dist/src/canvas/input.js +132 -0
- package/dist/src/canvas/input.js.map +10 -0
- package/dist/src/canvas/layout.js +309 -0
- package/dist/src/canvas/layout.js.map +10 -0
- package/dist/src/canvas/paint.js +878 -0
- package/dist/src/canvas/paint.js.map +10 -0
- package/dist/src/canvas/renderer.js +276 -0
- package/dist/src/canvas/renderer.js.map +10 -0
- package/dist/src/canvas/text.js +118 -0
- package/dist/src/canvas/text.js.map +10 -0
- package/dist/src/canvas/types.js +2 -0
- package/dist/src/canvas/types.js.map +9 -0
- package/dist/src/canvas/utils.js +139 -0
- package/dist/src/canvas/utils.js.map +10 -0
- package/dist/src/dom/applicators/advanced-layout.js +111 -0
- package/dist/src/dom/applicators/advanced-layout.js.map +10 -0
- package/dist/src/dom/applicators/background.js +54 -0
- package/dist/src/dom/applicators/background.js.map +10 -0
- package/dist/src/dom/applicators/border.js +33 -0
- package/dist/src/dom/applicators/border.js.map +10 -0
- package/dist/src/dom/applicators/color.js +36 -0
- package/dist/src/dom/applicators/color.js.map +10 -0
- package/dist/src/dom/applicators/display.js +57 -0
- package/dist/src/dom/applicators/display.js.map +10 -0
- package/dist/src/dom/applicators/effects.js +89 -0
- package/dist/src/dom/applicators/effects.js.map +10 -0
- package/dist/src/dom/applicators/events.js +518 -0
- package/dist/src/dom/applicators/events.js.map +10 -0
- package/dist/src/dom/applicators/font.js +39 -0
- package/dist/src/dom/applicators/font.js.map +10 -0
- package/dist/src/dom/applicators/index.js +296 -0
- package/dist/src/dom/applicators/index.js.map +10 -0
- package/dist/src/dom/applicators/layout.js +86 -0
- package/dist/src/dom/applicators/layout.js.map +10 -0
- package/dist/src/dom/applicators/margin.js +32 -0
- package/dist/src/dom/applicators/margin.js.map +10 -0
- package/dist/src/dom/applicators/padding.js +35 -0
- package/dist/src/dom/applicators/padding.js.map +10 -0
- package/dist/src/dom/applicators/size.js +42 -0
- package/dist/src/dom/applicators/size.js.map +10 -0
- package/dist/src/dom/applicators/transform.js +92 -0
- package/dist/src/dom/applicators/transform.js.map +10 -0
- package/dist/src/dom/applicators/transition.js +66 -0
- package/dist/src/dom/applicators/transition.js.map +10 -0
- package/dist/src/dom/applicators/typography.js +87 -0
- package/dist/src/dom/applicators/typography.js.map +10 -0
- package/dist/src/dom/canvas/index.js +50 -0
- package/dist/src/dom/canvas/index.js.map +10 -0
- package/dist/src/dom/components/audio.js +48 -0
- package/dist/src/dom/components/audio.js.map +10 -0
- package/dist/src/dom/components/avatar.js +58 -0
- package/dist/src/dom/components/avatar.js.map +10 -0
- package/dist/src/dom/components/badge.js +55 -0
- package/dist/src/dom/components/badge.js.map +10 -0
- package/dist/src/dom/components/button.js +29 -0
- package/dist/src/dom/components/button.js.map +10 -0
- package/dist/src/dom/components/card.js +33 -0
- package/dist/src/dom/components/card.js.map +10 -0
- package/dist/src/dom/components/center.js +32 -0
- package/dist/src/dom/components/center.js.map +10 -0
- package/dist/src/dom/components/checkbox.js +54 -0
- package/dist/src/dom/components/checkbox.js.map +10 -0
- package/dist/src/dom/components/column.js +31 -0
- package/dist/src/dom/components/column.js.map +10 -0
- package/dist/src/dom/components/container.js +29 -0
- package/dist/src/dom/components/container.js.map +10 -0
- package/dist/src/dom/components/divider.js +45 -0
- package/dist/src/dom/components/divider.js.map +10 -0
- package/dist/src/dom/components/grid.js +44 -0
- package/dist/src/dom/components/grid.js.map +10 -0
- package/dist/src/dom/components/heading.js +47 -0
- package/dist/src/dom/components/heading.js.map +10 -0
- package/dist/src/dom/components/image.js +39 -0
- package/dist/src/dom/components/image.js.map +10 -0
- package/dist/src/dom/components/index.js +217 -0
- package/dist/src/dom/components/index.js.map +10 -0
- package/dist/src/dom/components/input.js +41 -0
- package/dist/src/dom/components/input.js.map +10 -0
- package/dist/src/dom/components/link.js +42 -0
- package/dist/src/dom/components/link.js.map +10 -0
- package/dist/src/dom/components/list.js +42 -0
- package/dist/src/dom/components/list.js.map +10 -0
- package/dist/src/dom/components/paragraph.js +35 -0
- package/dist/src/dom/components/paragraph.js.map +10 -0
- package/dist/src/dom/components/progressbar.js +57 -0
- package/dist/src/dom/components/progressbar.js.map +10 -0
- package/dist/src/dom/components/route.js +44 -0
- package/dist/src/dom/components/route.js.map +10 -0
- package/dist/src/dom/components/router.js +33 -0
- package/dist/src/dom/components/router.js.map +10 -0
- package/dist/src/dom/components/row.js +31 -0
- package/dist/src/dom/components/row.js.map +10 -0
- package/dist/src/dom/components/select.js +57 -0
- package/dist/src/dom/components/select.js.map +10 -0
- package/dist/src/dom/components/slider.js +48 -0
- package/dist/src/dom/components/slider.js.map +10 -0
- package/dist/src/dom/components/spacer.js +30 -0
- package/dist/src/dom/components/spacer.js.map +10 -0
- package/dist/src/dom/components/spinner.js +65 -0
- package/dist/src/dom/components/spinner.js.map +10 -0
- package/dist/src/dom/components/stack.js +45 -0
- package/dist/src/dom/components/stack.js.map +10 -0
- package/dist/src/dom/components/switch.js +83 -0
- package/dist/src/dom/components/switch.js.map +10 -0
- package/dist/src/dom/components/text.js +37 -0
- package/dist/src/dom/components/text.js.map +10 -0
- package/dist/src/dom/components/textarea.js +51 -0
- package/dist/src/dom/components/textarea.js.map +10 -0
- package/dist/src/dom/components/video.js +51 -0
- package/dist/src/dom/components/video.js.map +10 -0
- package/dist/src/dom/debug.js +170 -0
- package/dist/src/dom/debug.js.map +10 -0
- package/dist/src/dom/events.js +112 -0
- package/dist/src/dom/events.js.map +10 -0
- package/dist/src/dom/index.js +73 -0
- package/dist/src/dom/index.js.map +9 -0
- package/dist/src/dom/renderer.js +277 -0
- package/dist/src/dom/renderer.js.map +10 -0
- package/dist/src/index.js +89 -0
- package/dist/src/index.js.map +9 -0
- package/package.json +84 -0
- package/src/canvas/QUICKSTART.md +421 -0
- package/src/canvas/README.md +376 -0
- package/src/canvas/accessibility.ts +218 -0
- package/src/canvas/events.ts +307 -0
- package/src/canvas/index.ts +35 -0
- package/src/canvas/input.ts +210 -0
- package/src/canvas/layout.ts +401 -0
- package/src/canvas/paint.ts +1321 -0
- package/src/canvas/renderer.ts +422 -0
- package/src/canvas/text.ts +182 -0
- package/src/canvas/types.ts +137 -0
- package/src/canvas/utils.ts +218 -0
- package/src/dom/README.md +265 -0
- package/src/dom/applicators/advanced-layout.ts +128 -0
- package/src/dom/applicators/background.ts +50 -0
- package/src/dom/applicators/border.ts +19 -0
- package/src/dom/applicators/color.ts +23 -0
- package/src/dom/applicators/display.ts +54 -0
- package/src/dom/applicators/effects.ts +97 -0
- package/src/dom/applicators/events.ts +689 -0
- package/src/dom/applicators/font.ts +27 -0
- package/src/dom/applicators/index.ts +354 -0
- package/src/dom/applicators/layout.ts +92 -0
- package/src/dom/applicators/margin.ts +18 -0
- package/src/dom/applicators/padding.ts +18 -0
- package/src/dom/applicators/size.ts +31 -0
- package/src/dom/applicators/transform.ts +93 -0
- package/src/dom/applicators/transition.ts +65 -0
- package/src/dom/applicators/typography.ts +91 -0
- package/src/dom/canvas/index.ts +60 -0
- package/src/dom/components/audio.ts +45 -0
- package/src/dom/components/avatar.ts +49 -0
- package/src/dom/components/badge.ts +45 -0
- package/src/dom/components/button.ts +13 -0
- package/src/dom/components/card.ts +19 -0
- package/src/dom/components/center.ts +16 -0
- package/src/dom/components/checkbox.ts +54 -0
- package/src/dom/components/column.ts +15 -0
- package/src/dom/components/container.ts +13 -0
- package/src/dom/components/divider.ts +37 -0
- package/src/dom/components/grid.ts +40 -0
- package/src/dom/components/heading.ts +41 -0
- package/src/dom/components/image.ts +27 -0
- package/src/dom/components/index.ts +115 -0
- package/src/dom/components/input.ts +29 -0
- package/src/dom/components/link.ts +35 -0
- package/src/dom/components/list.ts +30 -0
- package/src/dom/components/paragraph.ts +23 -0
- package/src/dom/components/progressbar.ts +51 -0
- package/src/dom/components/route.ts +37 -0
- package/src/dom/components/router.ts +22 -0
- package/src/dom/components/row.ts +15 -0
- package/src/dom/components/select.ts +56 -0
- package/src/dom/components/slider.ts +45 -0
- package/src/dom/components/spacer.ts +16 -0
- package/src/dom/components/spinner.ts +60 -0
- package/src/dom/components/stack.ts +34 -0
- package/src/dom/components/switch.ts +86 -0
- package/src/dom/components/text.ts +24 -0
- package/src/dom/components/textarea.ts +50 -0
- package/src/dom/components/video.ts +50 -0
- package/src/dom/debug.ts +247 -0
- package/src/dom/events.ts +168 -0
- package/src/dom/index.ts +11 -0
- package/src/dom/renderer.ts +327 -0
- package/src/index.ts +56 -0
|
@@ -0,0 +1,1321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paint System
|
|
3
|
+
*
|
|
4
|
+
* Drawing virtual nodes to canvas
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { VirtualNode, PainterFunction } from "./types.js";
|
|
8
|
+
import { renderText } from "./text.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Custom painters registry
|
|
12
|
+
*/
|
|
13
|
+
const customPainters = new Map<string, PainterFunction>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Register a custom painter for a component type
|
|
17
|
+
*/
|
|
18
|
+
export function registerPainter(type: string, painter: PainterFunction): void {
|
|
19
|
+
customPainters.set(type.toLowerCase(), painter);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Paint a virtual node and its children
|
|
24
|
+
*/
|
|
25
|
+
export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
26
|
+
if (!node.visible || !node.layout) return;
|
|
27
|
+
|
|
28
|
+
ctx.save();
|
|
29
|
+
|
|
30
|
+
// Apply transforms
|
|
31
|
+
applyTransforms(ctx, node);
|
|
32
|
+
|
|
33
|
+
// Apply opacity
|
|
34
|
+
if (node.opacity < 1) {
|
|
35
|
+
ctx.globalAlpha = node.opacity;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check for custom painter
|
|
39
|
+
const customPainter = customPainters.get(node.type.toLowerCase());
|
|
40
|
+
if (customPainter) {
|
|
41
|
+
customPainter(ctx, node);
|
|
42
|
+
ctx.restore();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Default painting based on type
|
|
47
|
+
switch (node.type.toLowerCase()) {
|
|
48
|
+
case "column":
|
|
49
|
+
case "row":
|
|
50
|
+
case "stack":
|
|
51
|
+
paintContainer(ctx, node);
|
|
52
|
+
break;
|
|
53
|
+
case "text":
|
|
54
|
+
paintText(ctx, node);
|
|
55
|
+
break;
|
|
56
|
+
case "button":
|
|
57
|
+
paintButton(ctx, node);
|
|
58
|
+
break;
|
|
59
|
+
case "input":
|
|
60
|
+
paintInput(ctx, node);
|
|
61
|
+
break;
|
|
62
|
+
case "image":
|
|
63
|
+
paintImage(ctx, node);
|
|
64
|
+
break;
|
|
65
|
+
case "spacer":
|
|
66
|
+
// Spacer is invisible, just takes up space
|
|
67
|
+
break;
|
|
68
|
+
case "divider":
|
|
69
|
+
case "separator":
|
|
70
|
+
paintDivider(ctx, node);
|
|
71
|
+
break;
|
|
72
|
+
case "checkbox":
|
|
73
|
+
paintCheckbox(ctx, node);
|
|
74
|
+
break;
|
|
75
|
+
case "radio":
|
|
76
|
+
paintRadio(ctx, node);
|
|
77
|
+
break;
|
|
78
|
+
case "switch":
|
|
79
|
+
case "toggle":
|
|
80
|
+
paintSwitch(ctx, node);
|
|
81
|
+
break;
|
|
82
|
+
case "slider":
|
|
83
|
+
paintSlider(ctx, node);
|
|
84
|
+
break;
|
|
85
|
+
case "progress":
|
|
86
|
+
case "progressbar":
|
|
87
|
+
paintProgress(ctx, node);
|
|
88
|
+
break;
|
|
89
|
+
case "spinner":
|
|
90
|
+
case "loading":
|
|
91
|
+
paintSpinner(ctx, node);
|
|
92
|
+
break;
|
|
93
|
+
case "card":
|
|
94
|
+
paintCard(ctx, node);
|
|
95
|
+
break;
|
|
96
|
+
case "badge":
|
|
97
|
+
paintBadge(ctx, node);
|
|
98
|
+
break;
|
|
99
|
+
case "avatar":
|
|
100
|
+
paintAvatar(ctx, node);
|
|
101
|
+
break;
|
|
102
|
+
case "icon":
|
|
103
|
+
paintIcon(ctx, node);
|
|
104
|
+
break;
|
|
105
|
+
case "link":
|
|
106
|
+
paintLink(ctx, node);
|
|
107
|
+
break;
|
|
108
|
+
case "container":
|
|
109
|
+
case "box":
|
|
110
|
+
paintContainer(ctx, node);
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
paintContainer(ctx, node);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
ctx.restore();
|
|
117
|
+
|
|
118
|
+
// Paint children
|
|
119
|
+
for (const child of node.children) {
|
|
120
|
+
paintNode(ctx, child);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Restore context if overflow clipping was applied
|
|
124
|
+
if ((node as any)._needsRestore) {
|
|
125
|
+
ctx.restore();
|
|
126
|
+
delete (node as any)._needsRestore;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Paint a container (Column, Row, Stack, Box)
|
|
132
|
+
*/
|
|
133
|
+
function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
134
|
+
const layout = node.layout!;
|
|
135
|
+
const props = node.props;
|
|
136
|
+
|
|
137
|
+
const x = layout.x;
|
|
138
|
+
const y = layout.y;
|
|
139
|
+
const width = layout.width;
|
|
140
|
+
const height = layout.height;
|
|
141
|
+
const radius = layout.border.radius;
|
|
142
|
+
|
|
143
|
+
// Apply shadow if specified
|
|
144
|
+
const shadow = props.shadow || props.boxShadow;
|
|
145
|
+
if (shadow) {
|
|
146
|
+
applyShadow(ctx, shadow);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Draw background
|
|
150
|
+
const backgroundColor = props.backgroundColor || props.background;
|
|
151
|
+
if (backgroundColor) {
|
|
152
|
+
// Support for gradients
|
|
153
|
+
if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
|
|
154
|
+
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
155
|
+
} else {
|
|
156
|
+
ctx.fillStyle = backgroundColor;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (radius > 0) {
|
|
160
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
161
|
+
ctx.fill();
|
|
162
|
+
} else {
|
|
163
|
+
ctx.fillRect(x, y, width, height);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Reset shadow for border
|
|
168
|
+
if (shadow) {
|
|
169
|
+
ctx.shadowColor = "transparent";
|
|
170
|
+
ctx.shadowBlur = 0;
|
|
171
|
+
ctx.shadowOffsetX = 0;
|
|
172
|
+
ctx.shadowOffsetY = 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Draw border
|
|
176
|
+
if (layout.border.width > 0 && layout.border.color !== "transparent") {
|
|
177
|
+
ctx.strokeStyle = layout.border.color;
|
|
178
|
+
ctx.lineWidth = layout.border.width;
|
|
179
|
+
if (radius > 0) {
|
|
180
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
181
|
+
ctx.stroke();
|
|
182
|
+
} else {
|
|
183
|
+
ctx.strokeRect(x, y, width, height);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Apply overflow clipping for children
|
|
188
|
+
const overflow = props.overflow || "visible";
|
|
189
|
+
if (overflow === "hidden" || overflow === "scroll" || overflow === "auto") {
|
|
190
|
+
ctx.save();
|
|
191
|
+
ctx.beginPath();
|
|
192
|
+
if (radius > 0) {
|
|
193
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
194
|
+
} else {
|
|
195
|
+
ctx.rect(x, y, width, height);
|
|
196
|
+
}
|
|
197
|
+
ctx.clip();
|
|
198
|
+
|
|
199
|
+
// Mark that we need to restore later
|
|
200
|
+
(node as any)._needsRestore = true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Paint text node
|
|
206
|
+
*/
|
|
207
|
+
function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
208
|
+
const layout = node.layout!;
|
|
209
|
+
const props = node.props;
|
|
210
|
+
|
|
211
|
+
let text = String(props[0] || props.text || "");
|
|
212
|
+
const color = props.color || "#000000";
|
|
213
|
+
const fontSize = parseFloat(props.fontSize) || 16;
|
|
214
|
+
const fontWeight = props.fontWeight || "normal";
|
|
215
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
216
|
+
const textAlign = props.textAlign || "left";
|
|
217
|
+
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
218
|
+
const textDecoration = props.textDecoration || "none";
|
|
219
|
+
const textTransform = props.textTransform || "none";
|
|
220
|
+
const letterSpacing = parseFloat(props.letterSpacing) || 0;
|
|
221
|
+
|
|
222
|
+
// Apply text transform
|
|
223
|
+
if (textTransform === "uppercase") {
|
|
224
|
+
text = text.toUpperCase();
|
|
225
|
+
} else if (textTransform === "lowercase") {
|
|
226
|
+
text = text.toLowerCase();
|
|
227
|
+
} else if (textTransform === "capitalize") {
|
|
228
|
+
text = text.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Apply text shadow if specified
|
|
232
|
+
const textShadow = props.textShadow || props.shadow;
|
|
233
|
+
if (textShadow) {
|
|
234
|
+
applyShadow(ctx, textShadow);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Handle letter spacing
|
|
238
|
+
const x = layout.x + layout.contentX;
|
|
239
|
+
const y = layout.y + layout.contentY;
|
|
240
|
+
|
|
241
|
+
if (letterSpacing !== 0) {
|
|
242
|
+
// Manual letter spacing
|
|
243
|
+
ctx.fillStyle = color;
|
|
244
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
245
|
+
ctx.textAlign = "left";
|
|
246
|
+
ctx.textBaseline = "top";
|
|
247
|
+
|
|
248
|
+
let currentX = x;
|
|
249
|
+
for (let i = 0; i < text.length; i++) {
|
|
250
|
+
ctx.fillText(text[i], currentX, y);
|
|
251
|
+
currentX += ctx.measureText(text[i]).width;
|
|
252
|
+
// Add letter spacing after each character except the last
|
|
253
|
+
if (i < text.length - 1) {
|
|
254
|
+
currentX += letterSpacing;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Text decoration with letter spacing (now correct width)
|
|
259
|
+
if (textDecoration !== "none") {
|
|
260
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, currentX - x, fontSize);
|
|
261
|
+
}
|
|
262
|
+
} else {
|
|
263
|
+
// Normal text rendering
|
|
264
|
+
renderText(
|
|
265
|
+
ctx,
|
|
266
|
+
text,
|
|
267
|
+
x,
|
|
268
|
+
y,
|
|
269
|
+
layout.contentWidth,
|
|
270
|
+
layout.contentHeight,
|
|
271
|
+
{
|
|
272
|
+
color,
|
|
273
|
+
fontSize,
|
|
274
|
+
fontWeight,
|
|
275
|
+
fontFamily,
|
|
276
|
+
textAlign: textAlign as any,
|
|
277
|
+
verticalAlign: "top",
|
|
278
|
+
lineHeight,
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Text decoration (need to set font again for measurement)
|
|
283
|
+
if (textDecoration !== "none") {
|
|
284
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
285
|
+
const textWidth = ctx.measureText(text).width;
|
|
286
|
+
applyTextDecoration(ctx, textDecoration, color, x, y, textWidth, fontSize);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Reset shadow
|
|
291
|
+
if (textShadow) {
|
|
292
|
+
ctx.shadowColor = "transparent";
|
|
293
|
+
ctx.shadowBlur = 0;
|
|
294
|
+
ctx.shadowOffsetX = 0;
|
|
295
|
+
ctx.shadowOffsetY = 0;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Paint button node
|
|
301
|
+
*/
|
|
302
|
+
function paintButton(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
303
|
+
const layout = node.layout!;
|
|
304
|
+
const props = node.props;
|
|
305
|
+
|
|
306
|
+
const x = layout.x;
|
|
307
|
+
const y = layout.y;
|
|
308
|
+
const width = layout.width;
|
|
309
|
+
const height = layout.height;
|
|
310
|
+
const radius = layout.border.radius || 4;
|
|
311
|
+
|
|
312
|
+
// Apply shadow if specified
|
|
313
|
+
const shadow = props.shadow || props.boxShadow;
|
|
314
|
+
if (shadow) {
|
|
315
|
+
applyShadow(ctx, shadow);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Background color based on state
|
|
319
|
+
let backgroundColor = props.backgroundColor || "#007bff";
|
|
320
|
+
if (node.hovered) {
|
|
321
|
+
backgroundColor = props.hoverColor || "#0056b3";
|
|
322
|
+
}
|
|
323
|
+
if (node.focused) {
|
|
324
|
+
backgroundColor = props.focusColor || "#004085";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Support gradients for button background
|
|
328
|
+
if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
|
|
329
|
+
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
330
|
+
} else {
|
|
331
|
+
ctx.fillStyle = backgroundColor;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Draw button background
|
|
335
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
336
|
+
ctx.fill();
|
|
337
|
+
|
|
338
|
+
// Reset shadow for border
|
|
339
|
+
if (shadow) {
|
|
340
|
+
ctx.shadowColor = "transparent";
|
|
341
|
+
ctx.shadowBlur = 0;
|
|
342
|
+
ctx.shadowOffsetX = 0;
|
|
343
|
+
ctx.shadowOffsetY = 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Draw border
|
|
347
|
+
if (layout.border.width > 0) {
|
|
348
|
+
ctx.strokeStyle = layout.border.color;
|
|
349
|
+
ctx.lineWidth = layout.border.width;
|
|
350
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
351
|
+
ctx.stroke();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Paint children (typically Text)
|
|
355
|
+
for (const child of node.children) {
|
|
356
|
+
paintNode(ctx, child);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Paint input node
|
|
362
|
+
*/
|
|
363
|
+
function paintInput(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
364
|
+
const layout = node.layout!;
|
|
365
|
+
const props = node.props;
|
|
366
|
+
|
|
367
|
+
const x = layout.x;
|
|
368
|
+
const y = layout.y;
|
|
369
|
+
const width = layout.width;
|
|
370
|
+
const height = layout.height;
|
|
371
|
+
const radius = layout.border.radius || 4;
|
|
372
|
+
|
|
373
|
+
// Background
|
|
374
|
+
ctx.fillStyle = props.backgroundColor || "#ffffff";
|
|
375
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
376
|
+
ctx.fill();
|
|
377
|
+
|
|
378
|
+
// Border (thicker if focused)
|
|
379
|
+
const borderColor = node.focused ? "#007bff" : (layout.border.color || "#cccccc");
|
|
380
|
+
const borderWidth = node.focused ? 2 : (layout.border.width || 1);
|
|
381
|
+
ctx.strokeStyle = borderColor;
|
|
382
|
+
ctx.lineWidth = borderWidth;
|
|
383
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
384
|
+
ctx.stroke();
|
|
385
|
+
|
|
386
|
+
// Input value text
|
|
387
|
+
const value = props.value || "";
|
|
388
|
+
const placeholder = props.placeholder || "";
|
|
389
|
+
const text = value || placeholder;
|
|
390
|
+
const textColor = value ? (props.color || "#000000") : "#999999";
|
|
391
|
+
|
|
392
|
+
if (text) {
|
|
393
|
+
const fontSize = parseFloat(props.fontSize) || 16;
|
|
394
|
+
const fontWeight = props.fontWeight || "normal";
|
|
395
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
396
|
+
const lineHeight = parseFloat(props.lineHeight) || fontSize * 1.2;
|
|
397
|
+
|
|
398
|
+
renderText(
|
|
399
|
+
ctx,
|
|
400
|
+
text,
|
|
401
|
+
layout.x + layout.contentX,
|
|
402
|
+
layout.y + layout.contentY,
|
|
403
|
+
layout.contentWidth,
|
|
404
|
+
layout.contentHeight,
|
|
405
|
+
{
|
|
406
|
+
color: textColor,
|
|
407
|
+
fontSize,
|
|
408
|
+
fontWeight,
|
|
409
|
+
fontFamily,
|
|
410
|
+
textAlign: "left",
|
|
411
|
+
verticalAlign: "middle",
|
|
412
|
+
lineHeight,
|
|
413
|
+
}
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Paint image node
|
|
420
|
+
*/
|
|
421
|
+
function paintImage(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
422
|
+
const layout = node.layout!;
|
|
423
|
+
const props = node.props;
|
|
424
|
+
|
|
425
|
+
const src = props.src || props[0];
|
|
426
|
+
if (!src) return;
|
|
427
|
+
|
|
428
|
+
// TODO: Load and cache images
|
|
429
|
+
// For now, just draw a placeholder
|
|
430
|
+
const x = layout.x;
|
|
431
|
+
const y = layout.y;
|
|
432
|
+
const width = layout.width;
|
|
433
|
+
const height = layout.height;
|
|
434
|
+
|
|
435
|
+
ctx.fillStyle = "#e0e0e0";
|
|
436
|
+
ctx.fillRect(x, y, width, height);
|
|
437
|
+
|
|
438
|
+
ctx.strokeStyle = "#999999";
|
|
439
|
+
ctx.lineWidth = 1;
|
|
440
|
+
ctx.strokeRect(x, y, width, height);
|
|
441
|
+
|
|
442
|
+
// Draw "IMG" text
|
|
443
|
+
ctx.fillStyle = "#666666";
|
|
444
|
+
ctx.font = "14px sans-serif";
|
|
445
|
+
ctx.textAlign = "center";
|
|
446
|
+
ctx.textBaseline = "middle";
|
|
447
|
+
ctx.fillText("IMG", x + width / 2, y + height / 2);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Draw rounded rectangle path
|
|
452
|
+
*/
|
|
453
|
+
function drawRoundedRect(
|
|
454
|
+
ctx: CanvasRenderingContext2D,
|
|
455
|
+
x: number,
|
|
456
|
+
y: number,
|
|
457
|
+
width: number,
|
|
458
|
+
height: number,
|
|
459
|
+
radius: number
|
|
460
|
+
): void {
|
|
461
|
+
if (radius <= 0) {
|
|
462
|
+
ctx.rect(x, y, width, height);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
ctx.beginPath();
|
|
467
|
+
ctx.moveTo(x + radius, y);
|
|
468
|
+
ctx.lineTo(x + width - radius, y);
|
|
469
|
+
ctx.arcTo(x + width, y, x + width, y + radius, radius);
|
|
470
|
+
ctx.lineTo(x + width, y + height - radius);
|
|
471
|
+
ctx.arcTo(x + width, y + height, x + width - radius, y + height, radius);
|
|
472
|
+
ctx.lineTo(x + radius, y + height);
|
|
473
|
+
ctx.arcTo(x, y + height, x, y + height - radius, radius);
|
|
474
|
+
ctx.lineTo(x, y + radius);
|
|
475
|
+
ctx.arcTo(x, y, x + radius, y, radius);
|
|
476
|
+
ctx.closePath();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Apply shadow to canvas context
|
|
481
|
+
* Supports both simple and CSS-like shadow syntax:
|
|
482
|
+
* - Simple: "2 2 4 rgba(0,0,0,0.3)"
|
|
483
|
+
* - CSS-like: "2px 2px 4px rgba(0,0,0,0.3)"
|
|
484
|
+
* - Named: {offsetX: 2, offsetY: 2, blur: 4, color: "rgba(0,0,0,0.3)"}
|
|
485
|
+
*/
|
|
486
|
+
function applyShadow(ctx: CanvasRenderingContext2D, shadow: any): void {
|
|
487
|
+
if (typeof shadow === "string") {
|
|
488
|
+
// Parse CSS-like shadow string: "offsetX offsetY blur color"
|
|
489
|
+
const parts = shadow.trim().split(/\s+/);
|
|
490
|
+
if (parts.length >= 3) {
|
|
491
|
+
const offsetX = parseFloat(parts[0]);
|
|
492
|
+
const offsetY = parseFloat(parts[1]);
|
|
493
|
+
const blur = parseFloat(parts[2]);
|
|
494
|
+
const color = parts.slice(3).join(" ") || "rgba(0,0,0,0.3)";
|
|
495
|
+
|
|
496
|
+
ctx.shadowOffsetX = offsetX;
|
|
497
|
+
ctx.shadowOffsetY = offsetY;
|
|
498
|
+
ctx.shadowBlur = blur;
|
|
499
|
+
ctx.shadowColor = color;
|
|
500
|
+
}
|
|
501
|
+
} else if (typeof shadow === "object") {
|
|
502
|
+
// Object syntax
|
|
503
|
+
ctx.shadowOffsetX = shadow.offsetX || 0;
|
|
504
|
+
ctx.shadowOffsetY = shadow.offsetY || 0;
|
|
505
|
+
ctx.shadowBlur = shadow.blur || 0;
|
|
506
|
+
ctx.shadowColor = shadow.color || "rgba(0,0,0,0.3)";
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Parse gradient string and create canvas gradient
|
|
512
|
+
* Supports:
|
|
513
|
+
* - linear-gradient(direction, color1, color2, ...)
|
|
514
|
+
* - radial-gradient(color1, color2, ...)
|
|
515
|
+
*/
|
|
516
|
+
function parseGradient(
|
|
517
|
+
ctx: CanvasRenderingContext2D,
|
|
518
|
+
gradientStr: string,
|
|
519
|
+
x: number,
|
|
520
|
+
y: number,
|
|
521
|
+
width: number,
|
|
522
|
+
height: number
|
|
523
|
+
): CanvasGradient | string {
|
|
524
|
+
// Simple gradient parsing
|
|
525
|
+
if (gradientStr.startsWith("linear-gradient")) {
|
|
526
|
+
// Extract content between parentheses
|
|
527
|
+
const match = gradientStr.match(/linear-gradient\((.*)\)/);
|
|
528
|
+
if (!match) return gradientStr;
|
|
529
|
+
|
|
530
|
+
const parts = match[1].split(",").map((s) => s.trim());
|
|
531
|
+
|
|
532
|
+
// Determine direction (default to bottom)
|
|
533
|
+
let x0 = x, y0 = y, x1 = x, y1 = y + height;
|
|
534
|
+
let colorStart = 0;
|
|
535
|
+
|
|
536
|
+
if (parts[0].includes("deg") || parts[0].includes("to ")) {
|
|
537
|
+
colorStart = 1;
|
|
538
|
+
const direction = parts[0];
|
|
539
|
+
|
|
540
|
+
if (direction.includes("to right") || direction === "90deg") {
|
|
541
|
+
x1 = x + width;
|
|
542
|
+
y1 = y;
|
|
543
|
+
} else if (direction.includes("to left") || direction === "270deg") {
|
|
544
|
+
x0 = x + width;
|
|
545
|
+
x1 = x;
|
|
546
|
+
y0 = y;
|
|
547
|
+
y1 = y;
|
|
548
|
+
} else if (direction.includes("to top") || direction === "0deg") {
|
|
549
|
+
y0 = y + height;
|
|
550
|
+
y1 = y;
|
|
551
|
+
}
|
|
552
|
+
// Default is "to bottom" which we already set
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
556
|
+
|
|
557
|
+
// Add color stops
|
|
558
|
+
const colors = parts.slice(colorStart);
|
|
559
|
+
colors.forEach((color, i) => {
|
|
560
|
+
const stop = i / (colors.length - 1);
|
|
561
|
+
gradient.addColorStop(stop, color.trim());
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
return gradient;
|
|
565
|
+
} else if (gradientStr.startsWith("radial-gradient")) {
|
|
566
|
+
// Extract content between parentheses
|
|
567
|
+
const match = gradientStr.match(/radial-gradient\((.*)\)/);
|
|
568
|
+
if (!match) return gradientStr;
|
|
569
|
+
|
|
570
|
+
const parts = match[1].split(",").map((s) => s.trim());
|
|
571
|
+
|
|
572
|
+
// Create radial gradient from center
|
|
573
|
+
const centerX = x + width / 2;
|
|
574
|
+
const centerY = y + height / 2;
|
|
575
|
+
const radius = Math.max(width, height) / 2;
|
|
576
|
+
|
|
577
|
+
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
|
578
|
+
|
|
579
|
+
// Add color stops
|
|
580
|
+
parts.forEach((color, i) => {
|
|
581
|
+
const stop = i / (parts.length - 1);
|
|
582
|
+
gradient.addColorStop(stop, color.trim());
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
return gradient;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return gradientStr;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Apply CSS-like transforms to canvas context
|
|
593
|
+
* Supports:
|
|
594
|
+
* - translate(x, y)
|
|
595
|
+
* - rotate(angle)
|
|
596
|
+
* - scale(x, y)
|
|
597
|
+
* - skew(x, y)
|
|
598
|
+
* - transform: "translate(10, 20) rotate(45) scale(1.5)"
|
|
599
|
+
*/
|
|
600
|
+
function applyTransforms(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
601
|
+
const props = node.props;
|
|
602
|
+
const layout = node.layout!;
|
|
603
|
+
|
|
604
|
+
// Get transform origin (default to center)
|
|
605
|
+
const originX = parseFloat(props.transformOriginX) || 0.5;
|
|
606
|
+
const originY = parseFloat(props.transformOriginY) || 0.5;
|
|
607
|
+
|
|
608
|
+
const centerX = layout.x + layout.width * originX;
|
|
609
|
+
const centerY = layout.y + layout.height * originY;
|
|
610
|
+
|
|
611
|
+
// Check for individual transform properties
|
|
612
|
+
const translateX = parseFloat(props.translateX) || 0;
|
|
613
|
+
const translateY = parseFloat(props.translateY) || 0;
|
|
614
|
+
const rotate = parseFloat(props.rotate) || 0; // in degrees
|
|
615
|
+
const scaleX = parseFloat(props.scaleX) || parseFloat(props.scale) || 1;
|
|
616
|
+
const scaleY = parseFloat(props.scaleY) || parseFloat(props.scale) || 1;
|
|
617
|
+
const skewX = parseFloat(props.skewX) || parseFloat(props.skew) || 0; // in degrees
|
|
618
|
+
const skewY = parseFloat(props.skewY) || 0; // in degrees
|
|
619
|
+
|
|
620
|
+
// Apply transforms in order: translate to origin, scale, rotate, skew, translate back, translate by offset
|
|
621
|
+
if (translateX !== 0 || translateY !== 0 || rotate !== 0 || scaleX !== 1 || scaleY !== 1 || skewX !== 0 || skewY !== 0) {
|
|
622
|
+
// Move to transform origin
|
|
623
|
+
ctx.translate(centerX, centerY);
|
|
624
|
+
|
|
625
|
+
// Apply scale
|
|
626
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
627
|
+
ctx.scale(scaleX, scaleY);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Apply rotation (convert degrees to radians)
|
|
631
|
+
if (rotate !== 0) {
|
|
632
|
+
ctx.rotate((rotate * Math.PI) / 180);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Apply skew using transform matrix
|
|
636
|
+
if (skewX !== 0 || skewY !== 0) {
|
|
637
|
+
const skewXRad = (skewX * Math.PI) / 180;
|
|
638
|
+
const skewYRad = (skewY * Math.PI) / 180;
|
|
639
|
+
ctx.transform(1, Math.tan(skewYRad), Math.tan(skewXRad), 1, 0, 0);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Move back from origin and apply translation
|
|
643
|
+
ctx.translate(-centerX + translateX, -centerY + translateY);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Also support compound transform string (optional, for future extensibility)
|
|
647
|
+
if (props.transform && typeof props.transform === "string") {
|
|
648
|
+
parseTransformString(ctx, props.transform, centerX, centerY);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Parse and apply a CSS-like transform string
|
|
654
|
+
*/
|
|
655
|
+
function parseTransformString(
|
|
656
|
+
ctx: CanvasRenderingContext2D,
|
|
657
|
+
transformStr: string,
|
|
658
|
+
originX: number,
|
|
659
|
+
originY: number
|
|
660
|
+
): void {
|
|
661
|
+
// Simple transform parsing - matches translate(), rotate(), scale()
|
|
662
|
+
const transforms = transformStr.match(/(\w+)\(([^)]+)\)/g);
|
|
663
|
+
if (!transforms) return;
|
|
664
|
+
|
|
665
|
+
ctx.translate(originX, originY);
|
|
666
|
+
|
|
667
|
+
for (const transform of transforms) {
|
|
668
|
+
const match = transform.match(/(\w+)\(([^)]+)\)/);
|
|
669
|
+
if (!match) continue;
|
|
670
|
+
|
|
671
|
+
const [, func, args] = match;
|
|
672
|
+
const values = args.split(",").map((v) => parseFloat(v.trim()));
|
|
673
|
+
|
|
674
|
+
switch (func.toLowerCase()) {
|
|
675
|
+
case "translate":
|
|
676
|
+
ctx.translate(values[0] || 0, values[1] || 0);
|
|
677
|
+
break;
|
|
678
|
+
case "rotate":
|
|
679
|
+
ctx.rotate((values[0] * Math.PI) / 180);
|
|
680
|
+
break;
|
|
681
|
+
case "scale":
|
|
682
|
+
ctx.scale(values[0] || 1, values[1] || values[0] || 1);
|
|
683
|
+
break;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
ctx.translate(-originX, -originY);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Paint divider/separator node
|
|
692
|
+
*/
|
|
693
|
+
function paintDivider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
694
|
+
const layout = node.layout!;
|
|
695
|
+
const props = node.props;
|
|
696
|
+
|
|
697
|
+
const orientation = props.orientation || "horizontal";
|
|
698
|
+
const color = props.color || props.backgroundColor || "#e0e0e0";
|
|
699
|
+
const thickness = parseFloat(props.thickness) || 1;
|
|
700
|
+
|
|
701
|
+
ctx.strokeStyle = color;
|
|
702
|
+
ctx.lineWidth = thickness;
|
|
703
|
+
ctx.beginPath();
|
|
704
|
+
|
|
705
|
+
if (orientation === "vertical") {
|
|
706
|
+
const x = layout.x + layout.width / 2;
|
|
707
|
+
ctx.moveTo(x, layout.y);
|
|
708
|
+
ctx.lineTo(x, layout.y + layout.height);
|
|
709
|
+
} else {
|
|
710
|
+
const y = layout.y + layout.height / 2;
|
|
711
|
+
ctx.moveTo(layout.x, y);
|
|
712
|
+
ctx.lineTo(layout.x + layout.width, y);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
ctx.stroke();
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Paint checkbox node
|
|
720
|
+
*/
|
|
721
|
+
function paintCheckbox(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
722
|
+
const layout = node.layout!;
|
|
723
|
+
const props = node.props;
|
|
724
|
+
|
|
725
|
+
const size = Math.min(layout.width, layout.height);
|
|
726
|
+
const x = layout.x;
|
|
727
|
+
const y = layout.y;
|
|
728
|
+
|
|
729
|
+
// Properly parse boolean values (handle string "false")
|
|
730
|
+
const checkedValue = props.checked !== undefined ? props.checked : props.value;
|
|
731
|
+
const checked = checkedValue === true || checkedValue === "true" || (checkedValue !== false && checkedValue !== "false" && !!checkedValue);
|
|
732
|
+
|
|
733
|
+
const radius = parseFloat(props.borderRadius) || 2;
|
|
734
|
+
|
|
735
|
+
// Background
|
|
736
|
+
const bgColor = checked ? (props.checkedColor || "#007bff") : (props.backgroundColor || "#ffffff");
|
|
737
|
+
ctx.fillStyle = bgColor;
|
|
738
|
+
drawRoundedRect(ctx, x, y, size, size, radius);
|
|
739
|
+
ctx.fill();
|
|
740
|
+
|
|
741
|
+
// Border
|
|
742
|
+
const borderColor = checked ? (props.checkedColor || "#007bff") : (props.borderColor || "#cccccc");
|
|
743
|
+
ctx.strokeStyle = borderColor;
|
|
744
|
+
ctx.lineWidth = node.focused ? 2 : 1;
|
|
745
|
+
drawRoundedRect(ctx, x, y, size, size, radius);
|
|
746
|
+
ctx.stroke();
|
|
747
|
+
|
|
748
|
+
// Checkmark
|
|
749
|
+
if (checked) {
|
|
750
|
+
ctx.strokeStyle = props.checkColor || "#ffffff";
|
|
751
|
+
ctx.lineWidth = 2;
|
|
752
|
+
ctx.lineCap = "round";
|
|
753
|
+
ctx.lineJoin = "round";
|
|
754
|
+
|
|
755
|
+
const padding = size * 0.25;
|
|
756
|
+
ctx.beginPath();
|
|
757
|
+
ctx.moveTo(x + padding, y + size / 2);
|
|
758
|
+
ctx.lineTo(x + size * 0.4, y + size - padding);
|
|
759
|
+
ctx.lineTo(x + size - padding, y + padding);
|
|
760
|
+
ctx.stroke();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Paint radio button node
|
|
766
|
+
*/
|
|
767
|
+
function paintRadio(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
768
|
+
const layout = node.layout!;
|
|
769
|
+
const props = node.props;
|
|
770
|
+
|
|
771
|
+
const size = Math.min(layout.width, layout.height);
|
|
772
|
+
const centerX = layout.x + size / 2;
|
|
773
|
+
const centerY = layout.y + size / 2;
|
|
774
|
+
const radius = size / 2;
|
|
775
|
+
|
|
776
|
+
// Properly parse boolean values (handle string "false")
|
|
777
|
+
const checkedValue = props.checked !== undefined ? props.checked : props.value;
|
|
778
|
+
const checked = checkedValue === true || checkedValue === "true" || (checkedValue !== false && checkedValue !== "false" && !!checkedValue);
|
|
779
|
+
|
|
780
|
+
// Outer circle background
|
|
781
|
+
const bgColor = props.backgroundColor || "#ffffff";
|
|
782
|
+
ctx.fillStyle = bgColor;
|
|
783
|
+
ctx.beginPath();
|
|
784
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
785
|
+
ctx.fill();
|
|
786
|
+
|
|
787
|
+
// Outer circle border
|
|
788
|
+
const borderColor = checked ? (props.checkedColor || "#007bff") : (props.borderColor || "#cccccc");
|
|
789
|
+
ctx.strokeStyle = borderColor;
|
|
790
|
+
ctx.lineWidth = node.focused ? 2 : 1;
|
|
791
|
+
ctx.beginPath();
|
|
792
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
793
|
+
ctx.stroke();
|
|
794
|
+
|
|
795
|
+
// Inner filled circle when checked
|
|
796
|
+
if (checked) {
|
|
797
|
+
ctx.fillStyle = props.checkedColor || "#007bff";
|
|
798
|
+
ctx.beginPath();
|
|
799
|
+
ctx.arc(centerX, centerY, radius * 0.5, 0, Math.PI * 2);
|
|
800
|
+
ctx.fill();
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Paint switch/toggle node
|
|
806
|
+
*/
|
|
807
|
+
function paintSwitch(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
808
|
+
const layout = node.layout!;
|
|
809
|
+
const props = node.props;
|
|
810
|
+
|
|
811
|
+
const width = layout.width;
|
|
812
|
+
const height = layout.height;
|
|
813
|
+
const x = layout.x;
|
|
814
|
+
const y = layout.y;
|
|
815
|
+
|
|
816
|
+
// Properly parse boolean values (handle string "false")
|
|
817
|
+
const checkedValue = props.checked !== undefined ? props.checked : props.value;
|
|
818
|
+
const checked = checkedValue === true || checkedValue === "true" || (checkedValue !== false && checkedValue !== "false" && !!checkedValue);
|
|
819
|
+
|
|
820
|
+
const radius = height / 2;
|
|
821
|
+
|
|
822
|
+
// Track background
|
|
823
|
+
const trackColor = checked ? (props.checkedColor || "#4caf50") : (props.backgroundColor || "#cccccc");
|
|
824
|
+
ctx.fillStyle = trackColor;
|
|
825
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
826
|
+
ctx.fill();
|
|
827
|
+
|
|
828
|
+
// Thumb (circle)
|
|
829
|
+
const thumbRadius = radius * 0.8;
|
|
830
|
+
const thumbX = checked ? (x + width - radius) : (x + radius);
|
|
831
|
+
const thumbY = y + radius;
|
|
832
|
+
|
|
833
|
+
ctx.fillStyle = props.thumbColor || "#ffffff";
|
|
834
|
+
ctx.beginPath();
|
|
835
|
+
ctx.arc(thumbX, thumbY, thumbRadius, 0, Math.PI * 2);
|
|
836
|
+
ctx.fill();
|
|
837
|
+
|
|
838
|
+
// Thumb shadow
|
|
839
|
+
if (props.shadow !== false) {
|
|
840
|
+
ctx.shadowColor = "rgba(0,0,0,0.2)";
|
|
841
|
+
ctx.shadowBlur = 2;
|
|
842
|
+
ctx.shadowOffsetY = 1;
|
|
843
|
+
ctx.beginPath();
|
|
844
|
+
ctx.arc(thumbX, thumbY, thumbRadius, 0, Math.PI * 2);
|
|
845
|
+
ctx.fill();
|
|
846
|
+
ctx.shadowColor = "transparent";
|
|
847
|
+
ctx.shadowBlur = 0;
|
|
848
|
+
ctx.shadowOffsetY = 0;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Paint slider node
|
|
854
|
+
*/
|
|
855
|
+
function paintSlider(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
856
|
+
const layout = node.layout!;
|
|
857
|
+
const props = node.props;
|
|
858
|
+
|
|
859
|
+
const width = layout.width;
|
|
860
|
+
const height = layout.height;
|
|
861
|
+
const x = layout.x;
|
|
862
|
+
const y = layout.y;
|
|
863
|
+
|
|
864
|
+
const min = parseFloat(props.min) || 0;
|
|
865
|
+
const max = parseFloat(props.max) || 100;
|
|
866
|
+
const value = parseFloat(props.value) || min;
|
|
867
|
+
const percentage = (value - min) / (max - min);
|
|
868
|
+
|
|
869
|
+
const trackHeight = parseFloat(props.trackHeight) || 4;
|
|
870
|
+
const thumbSize = parseFloat(props.thumbSize) || 16;
|
|
871
|
+
const trackY = y + (height - trackHeight) / 2;
|
|
872
|
+
|
|
873
|
+
// Track background
|
|
874
|
+
ctx.fillStyle = props.trackColor || "#e0e0e0";
|
|
875
|
+
drawRoundedRect(ctx, x, trackY, width, trackHeight, trackHeight / 2);
|
|
876
|
+
ctx.fill();
|
|
877
|
+
|
|
878
|
+
// Track fill
|
|
879
|
+
const fillWidth = width * percentage;
|
|
880
|
+
const fillColor = props.fillColor || props.color || "#007bff";
|
|
881
|
+
|
|
882
|
+
// Support gradient fills
|
|
883
|
+
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
884
|
+
ctx.fillStyle = parseGradient(ctx, fillColor, x, trackY, fillWidth, trackHeight);
|
|
885
|
+
} else {
|
|
886
|
+
ctx.fillStyle = fillColor;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
drawRoundedRect(ctx, x, trackY, fillWidth, trackHeight, trackHeight / 2);
|
|
890
|
+
ctx.fill();
|
|
891
|
+
|
|
892
|
+
// Thumb
|
|
893
|
+
const thumbX = x + fillWidth;
|
|
894
|
+
const thumbY = y + height / 2;
|
|
895
|
+
|
|
896
|
+
ctx.fillStyle = props.thumbColor || "#007bff";
|
|
897
|
+
ctx.beginPath();
|
|
898
|
+
ctx.arc(thumbX, thumbY, thumbSize / 2, 0, Math.PI * 2);
|
|
899
|
+
ctx.fill();
|
|
900
|
+
|
|
901
|
+
// Thumb border
|
|
902
|
+
ctx.strokeStyle = "#ffffff";
|
|
903
|
+
ctx.lineWidth = 2;
|
|
904
|
+
ctx.beginPath();
|
|
905
|
+
ctx.arc(thumbX, thumbY, thumbSize / 2, 0, Math.PI * 2);
|
|
906
|
+
ctx.stroke();
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Paint progress bar node
|
|
911
|
+
*/
|
|
912
|
+
function paintProgress(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
913
|
+
const layout = node.layout!;
|
|
914
|
+
const props = node.props;
|
|
915
|
+
|
|
916
|
+
const width = layout.width;
|
|
917
|
+
const height = layout.height;
|
|
918
|
+
const x = layout.x;
|
|
919
|
+
const y = layout.y;
|
|
920
|
+
|
|
921
|
+
const min = parseFloat(props.min) || 0;
|
|
922
|
+
const max = parseFloat(props.max) || 100;
|
|
923
|
+
const value = parseFloat(props.value) || 0;
|
|
924
|
+
const percentage = Math.min(Math.max((value - min) / (max - min), 0), 1);
|
|
925
|
+
const radius = layout.border.radius || height / 2;
|
|
926
|
+
|
|
927
|
+
// Background
|
|
928
|
+
ctx.fillStyle = props.backgroundColor || "#e0e0e0";
|
|
929
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
930
|
+
ctx.fill();
|
|
931
|
+
|
|
932
|
+
// Progress fill
|
|
933
|
+
if (percentage > 0) {
|
|
934
|
+
const fillWidth = width * percentage;
|
|
935
|
+
|
|
936
|
+
// Support gradient fills
|
|
937
|
+
const fillColor = props.fillColor || props.color || "#007bff";
|
|
938
|
+
if (typeof fillColor === "string" && fillColor.includes("gradient")) {
|
|
939
|
+
ctx.fillStyle = parseGradient(ctx, fillColor, x, y, fillWidth, height);
|
|
940
|
+
} else {
|
|
941
|
+
ctx.fillStyle = fillColor;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
drawRoundedRect(ctx, x, y, fillWidth, height, radius);
|
|
945
|
+
ctx.fill();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Optional text label
|
|
949
|
+
if (props.showLabel) {
|
|
950
|
+
const label = props.label || `${Math.round(percentage * 100)}%`;
|
|
951
|
+
ctx.fillStyle = props.labelColor || "#ffffff";
|
|
952
|
+
ctx.font = `${props.fontSize || 12}px ${props.fontFamily || "sans-serif"}`;
|
|
953
|
+
ctx.textAlign = "center";
|
|
954
|
+
ctx.textBaseline = "middle";
|
|
955
|
+
ctx.fillText(label, x + width / 2, y + height / 2);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Paint spinner/loading node
|
|
961
|
+
*/
|
|
962
|
+
function paintSpinner(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
963
|
+
const layout = node.layout!;
|
|
964
|
+
const props = node.props;
|
|
965
|
+
|
|
966
|
+
const size = Math.min(layout.width, layout.height);
|
|
967
|
+
const centerX = layout.x + size / 2;
|
|
968
|
+
const centerY = layout.y + size / 2;
|
|
969
|
+
const radius = size / 2 - 4;
|
|
970
|
+
const thickness = parseFloat(props.thickness) || 4;
|
|
971
|
+
const color = props.color || "#007bff";
|
|
972
|
+
|
|
973
|
+
// Use timestamp for animation if available
|
|
974
|
+
const rotation = (Date.now() / 1000) * Math.PI; // Rotate based on time
|
|
975
|
+
|
|
976
|
+
ctx.strokeStyle = color;
|
|
977
|
+
ctx.lineWidth = thickness;
|
|
978
|
+
ctx.lineCap = "round";
|
|
979
|
+
|
|
980
|
+
// Draw circular arc
|
|
981
|
+
ctx.beginPath();
|
|
982
|
+
ctx.arc(centerX, centerY, radius, rotation, rotation + Math.PI * 1.5);
|
|
983
|
+
ctx.stroke();
|
|
984
|
+
|
|
985
|
+
// Fade out effect
|
|
986
|
+
ctx.globalAlpha = 0.3;
|
|
987
|
+
ctx.beginPath();
|
|
988
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
989
|
+
ctx.stroke();
|
|
990
|
+
ctx.globalAlpha = 1;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/**
|
|
994
|
+
* Paint card node
|
|
995
|
+
*/
|
|
996
|
+
function paintCard(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
997
|
+
const layout = node.layout!;
|
|
998
|
+
const props = node.props;
|
|
999
|
+
|
|
1000
|
+
const x = layout.x;
|
|
1001
|
+
const y = layout.y;
|
|
1002
|
+
const width = layout.width;
|
|
1003
|
+
const height = layout.height;
|
|
1004
|
+
const radius = layout.border.radius || 8;
|
|
1005
|
+
|
|
1006
|
+
// Default card shadow
|
|
1007
|
+
const shadow = props.shadow || props.boxShadow || "0 2 8 rgba(0,0,0,0.1)";
|
|
1008
|
+
applyShadow(ctx, shadow);
|
|
1009
|
+
|
|
1010
|
+
// Background
|
|
1011
|
+
const backgroundColor = props.backgroundColor || "#ffffff";
|
|
1012
|
+
if (typeof backgroundColor === "string" && backgroundColor.includes("gradient")) {
|
|
1013
|
+
ctx.fillStyle = parseGradient(ctx, backgroundColor, x, y, width, height);
|
|
1014
|
+
} else {
|
|
1015
|
+
ctx.fillStyle = backgroundColor;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1019
|
+
ctx.fill();
|
|
1020
|
+
|
|
1021
|
+
// Reset shadow
|
|
1022
|
+
ctx.shadowColor = "transparent";
|
|
1023
|
+
ctx.shadowBlur = 0;
|
|
1024
|
+
ctx.shadowOffsetX = 0;
|
|
1025
|
+
ctx.shadowOffsetY = 0;
|
|
1026
|
+
|
|
1027
|
+
// Border
|
|
1028
|
+
if (layout.border.width > 0) {
|
|
1029
|
+
ctx.strokeStyle = layout.border.color;
|
|
1030
|
+
ctx.lineWidth = layout.border.width;
|
|
1031
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1032
|
+
ctx.stroke();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// Paint children
|
|
1036
|
+
for (const child of node.children) {
|
|
1037
|
+
paintNode(ctx, child);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Paint badge node
|
|
1043
|
+
*/
|
|
1044
|
+
function paintBadge(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1045
|
+
const layout = node.layout!;
|
|
1046
|
+
const props = node.props;
|
|
1047
|
+
|
|
1048
|
+
const x = layout.x;
|
|
1049
|
+
const y = layout.y;
|
|
1050
|
+
const width = layout.width;
|
|
1051
|
+
const height = layout.height;
|
|
1052
|
+
const radius = layout.border.radius || height / 2;
|
|
1053
|
+
|
|
1054
|
+
// Background
|
|
1055
|
+
const backgroundColor = props.backgroundColor || "#dc3545";
|
|
1056
|
+
ctx.fillStyle = backgroundColor;
|
|
1057
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1058
|
+
ctx.fill();
|
|
1059
|
+
|
|
1060
|
+
// Text content
|
|
1061
|
+
const text = String(props[0] || props.text || "");
|
|
1062
|
+
if (text) {
|
|
1063
|
+
ctx.fillStyle = props.color || "#ffffff";
|
|
1064
|
+
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || 10}px ${props.fontFamily || "sans-serif"}`;
|
|
1065
|
+
ctx.textAlign = "center";
|
|
1066
|
+
ctx.textBaseline = "middle";
|
|
1067
|
+
ctx.fillText(text, x + width / 2, y + height / 2);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Paint avatar node
|
|
1073
|
+
*/
|
|
1074
|
+
function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1075
|
+
const layout = node.layout!;
|
|
1076
|
+
const props = node.props;
|
|
1077
|
+
|
|
1078
|
+
const size = Math.min(layout.width, layout.height);
|
|
1079
|
+
const centerX = layout.x + size / 2;
|
|
1080
|
+
const centerY = layout.y + size / 2;
|
|
1081
|
+
const radius = size / 2;
|
|
1082
|
+
|
|
1083
|
+
// Clip to circle
|
|
1084
|
+
ctx.save();
|
|
1085
|
+
ctx.beginPath();
|
|
1086
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1087
|
+
ctx.clip();
|
|
1088
|
+
|
|
1089
|
+
// Background
|
|
1090
|
+
const backgroundColor = props.backgroundColor || "#cccccc";
|
|
1091
|
+
ctx.fillStyle = backgroundColor;
|
|
1092
|
+
ctx.beginPath();
|
|
1093
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1094
|
+
ctx.fill();
|
|
1095
|
+
|
|
1096
|
+
// Text initials if provided
|
|
1097
|
+
const text = String(props[0] || props.text || props.initials || "");
|
|
1098
|
+
if (text) {
|
|
1099
|
+
ctx.fillStyle = props.color || "#ffffff";
|
|
1100
|
+
ctx.font = `${props.fontWeight || "bold"} ${props.fontSize || size / 2.5}px ${props.fontFamily || "sans-serif"}`;
|
|
1101
|
+
ctx.textAlign = "center";
|
|
1102
|
+
ctx.textBaseline = "middle";
|
|
1103
|
+
ctx.fillText(text, centerX, centerY);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
ctx.restore();
|
|
1107
|
+
|
|
1108
|
+
// Border
|
|
1109
|
+
if (layout.border.width > 0) {
|
|
1110
|
+
ctx.strokeStyle = layout.border.color;
|
|
1111
|
+
ctx.lineWidth = layout.border.width;
|
|
1112
|
+
ctx.beginPath();
|
|
1113
|
+
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
|
1114
|
+
ctx.stroke();
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Paint icon node (simple placeholder - real icons would need SVG path support)
|
|
1120
|
+
*/
|
|
1121
|
+
function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1122
|
+
const layout = node.layout!;
|
|
1123
|
+
const props = node.props;
|
|
1124
|
+
|
|
1125
|
+
const size = Math.min(layout.width, layout.height);
|
|
1126
|
+
const x = layout.x;
|
|
1127
|
+
const y = layout.y;
|
|
1128
|
+
const color = props.color || "#000000";
|
|
1129
|
+
const iconName = props.icon || props.name || "circle";
|
|
1130
|
+
|
|
1131
|
+
ctx.fillStyle = color;
|
|
1132
|
+
ctx.strokeStyle = color;
|
|
1133
|
+
ctx.lineWidth = 2;
|
|
1134
|
+
|
|
1135
|
+
// Simple icon shapes
|
|
1136
|
+
switch (iconName.toLowerCase()) {
|
|
1137
|
+
case "circle":
|
|
1138
|
+
ctx.beginPath();
|
|
1139
|
+
ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
|
|
1140
|
+
ctx.fill();
|
|
1141
|
+
break;
|
|
1142
|
+
|
|
1143
|
+
case "square":
|
|
1144
|
+
const padding = size * 0.2;
|
|
1145
|
+
ctx.fillRect(x + padding, y + padding, size - padding * 2, size - padding * 2);
|
|
1146
|
+
break;
|
|
1147
|
+
|
|
1148
|
+
case "star":
|
|
1149
|
+
drawStar(ctx, x + size / 2, y + size / 2, 5, size / 3, size / 6);
|
|
1150
|
+
ctx.fill();
|
|
1151
|
+
break;
|
|
1152
|
+
|
|
1153
|
+
case "check":
|
|
1154
|
+
case "checkmark":
|
|
1155
|
+
const p = size * 0.2;
|
|
1156
|
+
ctx.beginPath();
|
|
1157
|
+
ctx.moveTo(x + p, y + size / 2);
|
|
1158
|
+
ctx.lineTo(x + size * 0.4, y + size - p);
|
|
1159
|
+
ctx.lineTo(x + size - p, y + p);
|
|
1160
|
+
ctx.stroke();
|
|
1161
|
+
break;
|
|
1162
|
+
|
|
1163
|
+
case "x":
|
|
1164
|
+
case "close":
|
|
1165
|
+
const pd = size * 0.2;
|
|
1166
|
+
ctx.beginPath();
|
|
1167
|
+
ctx.moveTo(x + pd, y + pd);
|
|
1168
|
+
ctx.lineTo(x + size - pd, y + size - pd);
|
|
1169
|
+
ctx.moveTo(x + size - pd, y + pd);
|
|
1170
|
+
ctx.lineTo(x + pd, y + size - pd);
|
|
1171
|
+
ctx.stroke();
|
|
1172
|
+
break;
|
|
1173
|
+
|
|
1174
|
+
default:
|
|
1175
|
+
// Default: filled circle
|
|
1176
|
+
ctx.beginPath();
|
|
1177
|
+
ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
|
|
1178
|
+
ctx.fill();
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Paint link node
|
|
1184
|
+
*/
|
|
1185
|
+
function paintLink(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1186
|
+
const layout = node.layout!;
|
|
1187
|
+
const props = node.props;
|
|
1188
|
+
|
|
1189
|
+
const text = String(props[0] || props.text || "");
|
|
1190
|
+
const color = node.hovered ? (props.hoverColor || "#0056b3") : (props.color || "#007bff");
|
|
1191
|
+
const fontSize = parseFloat(props.fontSize) || 16;
|
|
1192
|
+
const fontWeight = props.fontWeight || "normal";
|
|
1193
|
+
const fontFamily = props.fontFamily || "system-ui, sans-serif";
|
|
1194
|
+
const textDecoration = props.textDecoration !== undefined ? props.textDecoration : "underline";
|
|
1195
|
+
|
|
1196
|
+
ctx.fillStyle = color;
|
|
1197
|
+
ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
|
|
1198
|
+
ctx.textAlign = "left";
|
|
1199
|
+
ctx.textBaseline = "top";
|
|
1200
|
+
|
|
1201
|
+
const x = layout.x + layout.contentX;
|
|
1202
|
+
const y = layout.y + layout.contentY;
|
|
1203
|
+
|
|
1204
|
+
ctx.fillText(text, x, y);
|
|
1205
|
+
|
|
1206
|
+
// Underline
|
|
1207
|
+
if (textDecoration === "underline") {
|
|
1208
|
+
const textWidth = ctx.measureText(text).width;
|
|
1209
|
+
ctx.strokeStyle = color;
|
|
1210
|
+
ctx.lineWidth = 1;
|
|
1211
|
+
ctx.beginPath();
|
|
1212
|
+
ctx.moveTo(x, y + fontSize + 2);
|
|
1213
|
+
ctx.lineTo(x + textWidth, y + fontSize + 2);
|
|
1214
|
+
ctx.stroke();
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* Apply text decoration (underline, line-through, etc.)
|
|
1220
|
+
*/
|
|
1221
|
+
function applyTextDecoration(
|
|
1222
|
+
ctx: CanvasRenderingContext2D,
|
|
1223
|
+
decoration: string,
|
|
1224
|
+
color: string,
|
|
1225
|
+
x: number,
|
|
1226
|
+
y: number,
|
|
1227
|
+
width: number,
|
|
1228
|
+
fontSize: number
|
|
1229
|
+
): void {
|
|
1230
|
+
ctx.strokeStyle = color;
|
|
1231
|
+
ctx.lineWidth = Math.max(1, fontSize / 16);
|
|
1232
|
+
|
|
1233
|
+
ctx.beginPath();
|
|
1234
|
+
|
|
1235
|
+
if (decoration === "underline") {
|
|
1236
|
+
const underlineY = y + fontSize + 2;
|
|
1237
|
+
ctx.moveTo(x, underlineY);
|
|
1238
|
+
ctx.lineTo(x + width, underlineY);
|
|
1239
|
+
} else if (decoration === "line-through" || decoration === "strikethrough") {
|
|
1240
|
+
const lineThroughY = y + fontSize / 2;
|
|
1241
|
+
ctx.moveTo(x, lineThroughY);
|
|
1242
|
+
ctx.lineTo(x + width, lineThroughY);
|
|
1243
|
+
} else if (decoration === "overline") {
|
|
1244
|
+
ctx.moveTo(x, y);
|
|
1245
|
+
ctx.lineTo(x + width, y);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
ctx.stroke();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Apply overflow clipping
|
|
1253
|
+
*/
|
|
1254
|
+
function applyOverflowClip(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1255
|
+
const props = node.props;
|
|
1256
|
+
const overflow = props.overflow || "visible";
|
|
1257
|
+
|
|
1258
|
+
if (overflow === "hidden" || overflow === "scroll" || overflow === "auto") {
|
|
1259
|
+
const layout = node.layout!;
|
|
1260
|
+
const x = layout.x;
|
|
1261
|
+
const y = layout.y;
|
|
1262
|
+
const width = layout.width;
|
|
1263
|
+
const height = layout.height;
|
|
1264
|
+
const radius = layout.border.radius;
|
|
1265
|
+
|
|
1266
|
+
ctx.save();
|
|
1267
|
+
ctx.beginPath();
|
|
1268
|
+
|
|
1269
|
+
if (radius > 0) {
|
|
1270
|
+
drawRoundedRect(ctx, x, y, width, height, radius);
|
|
1271
|
+
} else {
|
|
1272
|
+
ctx.rect(x, y, width, height);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
ctx.clip();
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Helper: Draw a star shape
|
|
1281
|
+
*/
|
|
1282
|
+
function drawStar(
|
|
1283
|
+
ctx: CanvasRenderingContext2D,
|
|
1284
|
+
cx: number,
|
|
1285
|
+
cy: number,
|
|
1286
|
+
spikes: number,
|
|
1287
|
+
outerRadius: number,
|
|
1288
|
+
innerRadius: number
|
|
1289
|
+
): void {
|
|
1290
|
+
let rot = (Math.PI / 2) * 3;
|
|
1291
|
+
let x = cx;
|
|
1292
|
+
let y = cy;
|
|
1293
|
+
const step = Math.PI / spikes;
|
|
1294
|
+
|
|
1295
|
+
ctx.beginPath();
|
|
1296
|
+
ctx.moveTo(cx, cy - outerRadius);
|
|
1297
|
+
|
|
1298
|
+
for (let i = 0; i < spikes; i++) {
|
|
1299
|
+
x = cx + Math.cos(rot) * outerRadius;
|
|
1300
|
+
y = cy + Math.sin(rot) * outerRadius;
|
|
1301
|
+
ctx.lineTo(x, y);
|
|
1302
|
+
rot += step;
|
|
1303
|
+
|
|
1304
|
+
x = cx + Math.cos(rot) * innerRadius;
|
|
1305
|
+
y = cy + Math.sin(rot) * innerRadius;
|
|
1306
|
+
ctx.lineTo(x, y);
|
|
1307
|
+
rot += step;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
ctx.lineTo(cx, cy - outerRadius);
|
|
1311
|
+
ctx.closePath();
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
|