@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.
Files changed (195) hide show
  1. package/dist/chunk-2s02mkzs.js +32 -0
  2. package/dist/chunk-2s02mkzs.js.map +9 -0
  3. package/dist/src/canvas/accessibility.js +152 -0
  4. package/dist/src/canvas/accessibility.js.map +10 -0
  5. package/dist/src/canvas/events.js +198 -0
  6. package/dist/src/canvas/events.js.map +10 -0
  7. package/dist/src/canvas/index.js +28 -0
  8. package/dist/src/canvas/index.js.map +9 -0
  9. package/dist/src/canvas/input.js +132 -0
  10. package/dist/src/canvas/input.js.map +10 -0
  11. package/dist/src/canvas/layout.js +309 -0
  12. package/dist/src/canvas/layout.js.map +10 -0
  13. package/dist/src/canvas/paint.js +878 -0
  14. package/dist/src/canvas/paint.js.map +10 -0
  15. package/dist/src/canvas/renderer.js +276 -0
  16. package/dist/src/canvas/renderer.js.map +10 -0
  17. package/dist/src/canvas/text.js +118 -0
  18. package/dist/src/canvas/text.js.map +10 -0
  19. package/dist/src/canvas/types.js +2 -0
  20. package/dist/src/canvas/types.js.map +9 -0
  21. package/dist/src/canvas/utils.js +139 -0
  22. package/dist/src/canvas/utils.js.map +10 -0
  23. package/dist/src/dom/applicators/advanced-layout.js +111 -0
  24. package/dist/src/dom/applicators/advanced-layout.js.map +10 -0
  25. package/dist/src/dom/applicators/background.js +54 -0
  26. package/dist/src/dom/applicators/background.js.map +10 -0
  27. package/dist/src/dom/applicators/border.js +33 -0
  28. package/dist/src/dom/applicators/border.js.map +10 -0
  29. package/dist/src/dom/applicators/color.js +36 -0
  30. package/dist/src/dom/applicators/color.js.map +10 -0
  31. package/dist/src/dom/applicators/display.js +57 -0
  32. package/dist/src/dom/applicators/display.js.map +10 -0
  33. package/dist/src/dom/applicators/effects.js +89 -0
  34. package/dist/src/dom/applicators/effects.js.map +10 -0
  35. package/dist/src/dom/applicators/events.js +518 -0
  36. package/dist/src/dom/applicators/events.js.map +10 -0
  37. package/dist/src/dom/applicators/font.js +39 -0
  38. package/dist/src/dom/applicators/font.js.map +10 -0
  39. package/dist/src/dom/applicators/index.js +296 -0
  40. package/dist/src/dom/applicators/index.js.map +10 -0
  41. package/dist/src/dom/applicators/layout.js +86 -0
  42. package/dist/src/dom/applicators/layout.js.map +10 -0
  43. package/dist/src/dom/applicators/margin.js +32 -0
  44. package/dist/src/dom/applicators/margin.js.map +10 -0
  45. package/dist/src/dom/applicators/padding.js +35 -0
  46. package/dist/src/dom/applicators/padding.js.map +10 -0
  47. package/dist/src/dom/applicators/size.js +42 -0
  48. package/dist/src/dom/applicators/size.js.map +10 -0
  49. package/dist/src/dom/applicators/transform.js +92 -0
  50. package/dist/src/dom/applicators/transform.js.map +10 -0
  51. package/dist/src/dom/applicators/transition.js +66 -0
  52. package/dist/src/dom/applicators/transition.js.map +10 -0
  53. package/dist/src/dom/applicators/typography.js +87 -0
  54. package/dist/src/dom/applicators/typography.js.map +10 -0
  55. package/dist/src/dom/canvas/index.js +50 -0
  56. package/dist/src/dom/canvas/index.js.map +10 -0
  57. package/dist/src/dom/components/audio.js +48 -0
  58. package/dist/src/dom/components/audio.js.map +10 -0
  59. package/dist/src/dom/components/avatar.js +58 -0
  60. package/dist/src/dom/components/avatar.js.map +10 -0
  61. package/dist/src/dom/components/badge.js +55 -0
  62. package/dist/src/dom/components/badge.js.map +10 -0
  63. package/dist/src/dom/components/button.js +29 -0
  64. package/dist/src/dom/components/button.js.map +10 -0
  65. package/dist/src/dom/components/card.js +33 -0
  66. package/dist/src/dom/components/card.js.map +10 -0
  67. package/dist/src/dom/components/center.js +32 -0
  68. package/dist/src/dom/components/center.js.map +10 -0
  69. package/dist/src/dom/components/checkbox.js +54 -0
  70. package/dist/src/dom/components/checkbox.js.map +10 -0
  71. package/dist/src/dom/components/column.js +31 -0
  72. package/dist/src/dom/components/column.js.map +10 -0
  73. package/dist/src/dom/components/container.js +29 -0
  74. package/dist/src/dom/components/container.js.map +10 -0
  75. package/dist/src/dom/components/divider.js +45 -0
  76. package/dist/src/dom/components/divider.js.map +10 -0
  77. package/dist/src/dom/components/grid.js +44 -0
  78. package/dist/src/dom/components/grid.js.map +10 -0
  79. package/dist/src/dom/components/heading.js +47 -0
  80. package/dist/src/dom/components/heading.js.map +10 -0
  81. package/dist/src/dom/components/image.js +39 -0
  82. package/dist/src/dom/components/image.js.map +10 -0
  83. package/dist/src/dom/components/index.js +217 -0
  84. package/dist/src/dom/components/index.js.map +10 -0
  85. package/dist/src/dom/components/input.js +41 -0
  86. package/dist/src/dom/components/input.js.map +10 -0
  87. package/dist/src/dom/components/link.js +42 -0
  88. package/dist/src/dom/components/link.js.map +10 -0
  89. package/dist/src/dom/components/list.js +42 -0
  90. package/dist/src/dom/components/list.js.map +10 -0
  91. package/dist/src/dom/components/paragraph.js +35 -0
  92. package/dist/src/dom/components/paragraph.js.map +10 -0
  93. package/dist/src/dom/components/progressbar.js +57 -0
  94. package/dist/src/dom/components/progressbar.js.map +10 -0
  95. package/dist/src/dom/components/route.js +44 -0
  96. package/dist/src/dom/components/route.js.map +10 -0
  97. package/dist/src/dom/components/router.js +33 -0
  98. package/dist/src/dom/components/router.js.map +10 -0
  99. package/dist/src/dom/components/row.js +31 -0
  100. package/dist/src/dom/components/row.js.map +10 -0
  101. package/dist/src/dom/components/select.js +57 -0
  102. package/dist/src/dom/components/select.js.map +10 -0
  103. package/dist/src/dom/components/slider.js +48 -0
  104. package/dist/src/dom/components/slider.js.map +10 -0
  105. package/dist/src/dom/components/spacer.js +30 -0
  106. package/dist/src/dom/components/spacer.js.map +10 -0
  107. package/dist/src/dom/components/spinner.js +65 -0
  108. package/dist/src/dom/components/spinner.js.map +10 -0
  109. package/dist/src/dom/components/stack.js +45 -0
  110. package/dist/src/dom/components/stack.js.map +10 -0
  111. package/dist/src/dom/components/switch.js +83 -0
  112. package/dist/src/dom/components/switch.js.map +10 -0
  113. package/dist/src/dom/components/text.js +37 -0
  114. package/dist/src/dom/components/text.js.map +10 -0
  115. package/dist/src/dom/components/textarea.js +51 -0
  116. package/dist/src/dom/components/textarea.js.map +10 -0
  117. package/dist/src/dom/components/video.js +51 -0
  118. package/dist/src/dom/components/video.js.map +10 -0
  119. package/dist/src/dom/debug.js +170 -0
  120. package/dist/src/dom/debug.js.map +10 -0
  121. package/dist/src/dom/events.js +112 -0
  122. package/dist/src/dom/events.js.map +10 -0
  123. package/dist/src/dom/index.js +73 -0
  124. package/dist/src/dom/index.js.map +9 -0
  125. package/dist/src/dom/renderer.js +277 -0
  126. package/dist/src/dom/renderer.js.map +10 -0
  127. package/dist/src/index.js +89 -0
  128. package/dist/src/index.js.map +9 -0
  129. package/package.json +84 -0
  130. package/src/canvas/QUICKSTART.md +421 -0
  131. package/src/canvas/README.md +376 -0
  132. package/src/canvas/accessibility.ts +218 -0
  133. package/src/canvas/events.ts +307 -0
  134. package/src/canvas/index.ts +35 -0
  135. package/src/canvas/input.ts +210 -0
  136. package/src/canvas/layout.ts +401 -0
  137. package/src/canvas/paint.ts +1321 -0
  138. package/src/canvas/renderer.ts +422 -0
  139. package/src/canvas/text.ts +182 -0
  140. package/src/canvas/types.ts +137 -0
  141. package/src/canvas/utils.ts +218 -0
  142. package/src/dom/README.md +265 -0
  143. package/src/dom/applicators/advanced-layout.ts +128 -0
  144. package/src/dom/applicators/background.ts +50 -0
  145. package/src/dom/applicators/border.ts +19 -0
  146. package/src/dom/applicators/color.ts +23 -0
  147. package/src/dom/applicators/display.ts +54 -0
  148. package/src/dom/applicators/effects.ts +97 -0
  149. package/src/dom/applicators/events.ts +689 -0
  150. package/src/dom/applicators/font.ts +27 -0
  151. package/src/dom/applicators/index.ts +354 -0
  152. package/src/dom/applicators/layout.ts +92 -0
  153. package/src/dom/applicators/margin.ts +18 -0
  154. package/src/dom/applicators/padding.ts +18 -0
  155. package/src/dom/applicators/size.ts +31 -0
  156. package/src/dom/applicators/transform.ts +93 -0
  157. package/src/dom/applicators/transition.ts +65 -0
  158. package/src/dom/applicators/typography.ts +91 -0
  159. package/src/dom/canvas/index.ts +60 -0
  160. package/src/dom/components/audio.ts +45 -0
  161. package/src/dom/components/avatar.ts +49 -0
  162. package/src/dom/components/badge.ts +45 -0
  163. package/src/dom/components/button.ts +13 -0
  164. package/src/dom/components/card.ts +19 -0
  165. package/src/dom/components/center.ts +16 -0
  166. package/src/dom/components/checkbox.ts +54 -0
  167. package/src/dom/components/column.ts +15 -0
  168. package/src/dom/components/container.ts +13 -0
  169. package/src/dom/components/divider.ts +37 -0
  170. package/src/dom/components/grid.ts +40 -0
  171. package/src/dom/components/heading.ts +41 -0
  172. package/src/dom/components/image.ts +27 -0
  173. package/src/dom/components/index.ts +115 -0
  174. package/src/dom/components/input.ts +29 -0
  175. package/src/dom/components/link.ts +35 -0
  176. package/src/dom/components/list.ts +30 -0
  177. package/src/dom/components/paragraph.ts +23 -0
  178. package/src/dom/components/progressbar.ts +51 -0
  179. package/src/dom/components/route.ts +37 -0
  180. package/src/dom/components/router.ts +22 -0
  181. package/src/dom/components/row.ts +15 -0
  182. package/src/dom/components/select.ts +56 -0
  183. package/src/dom/components/slider.ts +45 -0
  184. package/src/dom/components/spacer.ts +16 -0
  185. package/src/dom/components/spinner.ts +60 -0
  186. package/src/dom/components/stack.ts +34 -0
  187. package/src/dom/components/switch.ts +86 -0
  188. package/src/dom/components/text.ts +24 -0
  189. package/src/dom/components/textarea.ts +50 -0
  190. package/src/dom/components/video.ts +50 -0
  191. package/src/dom/debug.ts +247 -0
  192. package/src/dom/events.ts +168 -0
  193. package/src/dom/index.ts +11 -0
  194. package/src/dom/renderer.ts +327 -0
  195. 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
+