@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,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas Renderer
|
|
3
|
+
*
|
|
4
|
+
* Main renderer class that orchestrates layout, painting, and events
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Renderer, Patch } from "@hypen/core";
|
|
8
|
+
|
|
9
|
+
// Interface for the engine that canvas renderer needs
|
|
10
|
+
interface IEngine {
|
|
11
|
+
dispatchAction(name: string, payload?: any): void;
|
|
12
|
+
}
|
|
13
|
+
import type {
|
|
14
|
+
VirtualNode,
|
|
15
|
+
CanvasRendererOptions,
|
|
16
|
+
PainterFunction,
|
|
17
|
+
LayoutFunction,
|
|
18
|
+
} from "./types.js";
|
|
19
|
+
import { computeLayout } from "./layout.js";
|
|
20
|
+
import { paintNode, registerPainter } from "./paint.js";
|
|
21
|
+
import { CanvasEventManager } from "./events.js";
|
|
22
|
+
import { InputOverlay } from "./input.js";
|
|
23
|
+
import { AccessibilityLayer } from "./accessibility.js";
|
|
24
|
+
import { findNodeById } from "./utils.js";
|
|
25
|
+
|
|
26
|
+
const DEFAULT_OPTIONS: CanvasRendererOptions = {
|
|
27
|
+
devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
|
|
28
|
+
backgroundColor: "#ffffff",
|
|
29
|
+
enableAccessibility: true,
|
|
30
|
+
enableHitTesting: true,
|
|
31
|
+
enableInputOverlay: true,
|
|
32
|
+
enableDirtyRects: false,
|
|
33
|
+
enableLayerCaching: false,
|
|
34
|
+
maxLayerCacheSize: 10,
|
|
35
|
+
showLayoutBounds: false,
|
|
36
|
+
showDirtyRects: false,
|
|
37
|
+
logPerformance: false,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Canvas Renderer
|
|
42
|
+
*/
|
|
43
|
+
export class CanvasRenderer implements Renderer {
|
|
44
|
+
private canvas: HTMLCanvasElement;
|
|
45
|
+
private ctx: CanvasRenderingContext2D;
|
|
46
|
+
private engine: IEngine;
|
|
47
|
+
private options: CanvasRendererOptions;
|
|
48
|
+
|
|
49
|
+
private rootNode: VirtualNode | null = null;
|
|
50
|
+
private nodes = new Map<string, VirtualNode>();
|
|
51
|
+
|
|
52
|
+
private eventManager: CanvasEventManager;
|
|
53
|
+
private inputOverlay: InputOverlay;
|
|
54
|
+
private accessibilityLayer: AccessibilityLayer;
|
|
55
|
+
|
|
56
|
+
private rafId: number | null = null;
|
|
57
|
+
private needsRedraw = false;
|
|
58
|
+
|
|
59
|
+
private frameCount = 0;
|
|
60
|
+
private lastFrameTime = 0;
|
|
61
|
+
|
|
62
|
+
constructor(canvas: HTMLCanvasElement, engine: IEngine, options?: Partial<CanvasRendererOptions>) {
|
|
63
|
+
this.canvas = canvas;
|
|
64
|
+
this.engine = engine;
|
|
65
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
66
|
+
|
|
67
|
+
// Get context
|
|
68
|
+
const ctx = canvas.getContext("2d");
|
|
69
|
+
if (!ctx) {
|
|
70
|
+
throw new Error("Failed to get 2D context from canvas");
|
|
71
|
+
}
|
|
72
|
+
this.ctx = ctx;
|
|
73
|
+
|
|
74
|
+
// Setup HiDPI
|
|
75
|
+
this.setupHiDPI();
|
|
76
|
+
|
|
77
|
+
// Initialize subsystems
|
|
78
|
+
this.eventManager = new CanvasEventManager(canvas, engine);
|
|
79
|
+
this.inputOverlay = new InputOverlay(
|
|
80
|
+
(canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null)
|
|
81
|
+
);
|
|
82
|
+
this.accessibilityLayer = new AccessibilityLayer(
|
|
83
|
+
(canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null),
|
|
84
|
+
this.options.enableAccessibility || false
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Listen for redraw requests from event manager
|
|
88
|
+
this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
|
|
89
|
+
|
|
90
|
+
// Don't schedule initial render - wait for patches
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Setup HiDPI rendering
|
|
95
|
+
*/
|
|
96
|
+
private setupHiDPI(): void {
|
|
97
|
+
const dpr = this.options.devicePixelRatio || 1;
|
|
98
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
99
|
+
|
|
100
|
+
this.canvas.width = rect.width * dpr;
|
|
101
|
+
this.canvas.height = rect.height * dpr;
|
|
102
|
+
|
|
103
|
+
this.ctx.scale(dpr, dpr);
|
|
104
|
+
|
|
105
|
+
// Update canvas display size
|
|
106
|
+
this.canvas.style.width = `${rect.width}px`;
|
|
107
|
+
this.canvas.style.height = `${rect.height}px`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Apply patches from engine
|
|
112
|
+
*/
|
|
113
|
+
applyPatches(patches: Patch[]): void {
|
|
114
|
+
for (const patch of patches) {
|
|
115
|
+
this.applyPatch(patch);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Update accessibility layer
|
|
119
|
+
if (this.rootNode) {
|
|
120
|
+
this.accessibilityLayer.syncTree(this.rootNode);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Schedule redraw
|
|
124
|
+
this.scheduleRedraw();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply single patch
|
|
129
|
+
*/
|
|
130
|
+
private applyPatch(patch: Patch): void {
|
|
131
|
+
switch (patch.type) {
|
|
132
|
+
case "create":
|
|
133
|
+
this.onCreate(patch.id!, (patch as any).element_type!, patch.props || {});
|
|
134
|
+
break;
|
|
135
|
+
|
|
136
|
+
case "setProp":
|
|
137
|
+
this.onSetProp(patch.id!, patch.name!, patch.value);
|
|
138
|
+
break;
|
|
139
|
+
|
|
140
|
+
case "setText":
|
|
141
|
+
this.onSetText(patch.id!, patch.text!);
|
|
142
|
+
break;
|
|
143
|
+
|
|
144
|
+
case "insert":
|
|
145
|
+
this.onInsert((patch as any).parent_id!, patch.id!, (patch as any).before_id);
|
|
146
|
+
break;
|
|
147
|
+
|
|
148
|
+
case "move":
|
|
149
|
+
this.onMove((patch as any).parent_id!, patch.id!, (patch as any).before_id);
|
|
150
|
+
break;
|
|
151
|
+
|
|
152
|
+
case "remove":
|
|
153
|
+
this.onRemove(patch.id!);
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create new virtual node
|
|
160
|
+
*/
|
|
161
|
+
private onCreate(id: string, elementType: string, props: Record<string, any>): void {
|
|
162
|
+
const node: VirtualNode = {
|
|
163
|
+
id,
|
|
164
|
+
type: elementType,
|
|
165
|
+
props: props instanceof Map ? Object.fromEntries(props) : props,
|
|
166
|
+
children: [],
|
|
167
|
+
parent: null,
|
|
168
|
+
visible: true,
|
|
169
|
+
opacity: parseFloat(props.opacity) || 1,
|
|
170
|
+
clickable: elementType === "button" || !!props.onclick,
|
|
171
|
+
hoverable: true,
|
|
172
|
+
focusable: elementType === "input" || elementType === "textarea" || elementType === "button",
|
|
173
|
+
focused: false,
|
|
174
|
+
hovered: false,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
this.nodes.set(id, node);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Set property on node
|
|
182
|
+
*/
|
|
183
|
+
private onSetProp(id: string, name: string, value: any): void {
|
|
184
|
+
const node = this.nodes.get(id);
|
|
185
|
+
if (!node) return;
|
|
186
|
+
|
|
187
|
+
node.props[name] = value;
|
|
188
|
+
|
|
189
|
+
// Update computed properties
|
|
190
|
+
if (name === "visible") {
|
|
191
|
+
node.visible = !!value;
|
|
192
|
+
}
|
|
193
|
+
if (name === "opacity") {
|
|
194
|
+
node.opacity = parseFloat(value) || 1;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Update accessibility
|
|
198
|
+
this.accessibilityLayer.updateNode(node);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Set text on node
|
|
203
|
+
*/
|
|
204
|
+
private onSetText(id: string, text: string): void {
|
|
205
|
+
const node = this.nodes.get(id);
|
|
206
|
+
if (!node) return;
|
|
207
|
+
|
|
208
|
+
node.props[0] = text;
|
|
209
|
+
|
|
210
|
+
// Update accessibility
|
|
211
|
+
this.accessibilityLayer.updateNode(node);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Insert node into tree
|
|
216
|
+
*/
|
|
217
|
+
private onInsert(parentId: string, id: string, beforeId?: string): void {
|
|
218
|
+
const child = this.nodes.get(id);
|
|
219
|
+
if (!child) return;
|
|
220
|
+
|
|
221
|
+
// Check if this is setting the root node (parent_id === id === "root" or similar)
|
|
222
|
+
if (parentId === "root" && id === "root") {
|
|
223
|
+
this.rootNode = child;
|
|
224
|
+
this.eventManager.setRootNode(child);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Otherwise find the parent node
|
|
229
|
+
const parent = this.nodes.get(parentId);
|
|
230
|
+
if (!parent) {
|
|
231
|
+
// If parent is "root", this might be the first real root node
|
|
232
|
+
if (parentId === "root") {
|
|
233
|
+
this.rootNode = child;
|
|
234
|
+
this.eventManager.setRootNode(child);
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Insert child into parent
|
|
240
|
+
child.parent = parent;
|
|
241
|
+
|
|
242
|
+
if (beforeId) {
|
|
243
|
+
const beforeIndex = parent.children.findIndex((c) => c.id === beforeId);
|
|
244
|
+
if (beforeIndex >= 0) {
|
|
245
|
+
parent.children.splice(beforeIndex, 0, child);
|
|
246
|
+
} else {
|
|
247
|
+
parent.children.push(child);
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
parent.children.push(child);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Move node in tree
|
|
256
|
+
*/
|
|
257
|
+
private onMove(parentId: string, id: string, beforeId?: string): void {
|
|
258
|
+
// Remove from old parent
|
|
259
|
+
const node = this.nodes.get(id);
|
|
260
|
+
if (!node || !node.parent) return;
|
|
261
|
+
|
|
262
|
+
const oldParent = node.parent;
|
|
263
|
+
const oldIndex = oldParent.children.indexOf(node);
|
|
264
|
+
if (oldIndex >= 0) {
|
|
265
|
+
oldParent.children.splice(oldIndex, 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Insert into new location
|
|
269
|
+
this.onInsert(parentId, id, beforeId);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Remove node from tree
|
|
274
|
+
*/
|
|
275
|
+
private onRemove(id: string): void {
|
|
276
|
+
const node = this.nodes.get(id);
|
|
277
|
+
if (!node) return;
|
|
278
|
+
|
|
279
|
+
// Remove from parent
|
|
280
|
+
if (node.parent) {
|
|
281
|
+
const index = node.parent.children.indexOf(node);
|
|
282
|
+
if (index >= 0) {
|
|
283
|
+
node.parent.children.splice(index, 1);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Remove from root
|
|
288
|
+
if (this.rootNode === node) {
|
|
289
|
+
this.rootNode = null;
|
|
290
|
+
this.eventManager.setRootNode(null);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Remove from nodes map
|
|
294
|
+
this.nodes.delete(id);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Schedule redraw
|
|
299
|
+
*/
|
|
300
|
+
private scheduleRedraw(): void {
|
|
301
|
+
if (this.rafId !== null) return;
|
|
302
|
+
|
|
303
|
+
// Use requestAnimationFrame if available (browser), otherwise render immediately (tests)
|
|
304
|
+
if (typeof requestAnimationFrame !== "undefined") {
|
|
305
|
+
this.rafId = requestAnimationFrame(() => {
|
|
306
|
+
this.render();
|
|
307
|
+
this.rafId = null;
|
|
308
|
+
});
|
|
309
|
+
} else {
|
|
310
|
+
// In non-browser environments (tests), render immediately
|
|
311
|
+
this.render();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Main render function
|
|
317
|
+
*/
|
|
318
|
+
private render(): void {
|
|
319
|
+
const startTime = performance.now();
|
|
320
|
+
|
|
321
|
+
// Clear canvas
|
|
322
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
323
|
+
|
|
324
|
+
// Draw background
|
|
325
|
+
if (this.options.backgroundColor) {
|
|
326
|
+
this.ctx.fillStyle = this.options.backgroundColor;
|
|
327
|
+
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Layout and paint
|
|
331
|
+
if (this.rootNode) {
|
|
332
|
+
// Compute layout
|
|
333
|
+
const dpr = this.options.devicePixelRatio || 1;
|
|
334
|
+
computeLayout(
|
|
335
|
+
this.ctx,
|
|
336
|
+
this.rootNode,
|
|
337
|
+
this.canvas.width / dpr,
|
|
338
|
+
this.canvas.height / dpr
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Paint
|
|
342
|
+
paintNode(this.ctx, this.rootNode);
|
|
343
|
+
|
|
344
|
+
// Debug: show layout bounds
|
|
345
|
+
if (this.options.showLayoutBounds) {
|
|
346
|
+
this.drawLayoutBounds(this.rootNode);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Performance logging
|
|
351
|
+
if (this.options.logPerformance) {
|
|
352
|
+
const elapsed = performance.now() - startTime;
|
|
353
|
+
this.frameCount++;
|
|
354
|
+
if (performance.now() - this.lastFrameTime > 1000) {
|
|
355
|
+
console.log(`Canvas FPS: ${this.frameCount}, Last frame: ${elapsed.toFixed(2)}ms`);
|
|
356
|
+
this.frameCount = 0;
|
|
357
|
+
this.lastFrameTime = performance.now();
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Draw layout bounds for debugging
|
|
364
|
+
*/
|
|
365
|
+
private drawLayoutBounds(node: VirtualNode): void {
|
|
366
|
+
if (!node.layout) return;
|
|
367
|
+
|
|
368
|
+
const layout = node.layout;
|
|
369
|
+
|
|
370
|
+
this.ctx.strokeStyle = "#ff0000";
|
|
371
|
+
this.ctx.lineWidth = 1;
|
|
372
|
+
this.ctx.strokeRect(layout.x, layout.y, layout.width, layout.height);
|
|
373
|
+
|
|
374
|
+
for (const child of node.children) {
|
|
375
|
+
this.drawLayoutBounds(child);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Get node by ID
|
|
381
|
+
*/
|
|
382
|
+
getNode(id: string): VirtualNode | undefined {
|
|
383
|
+
return this.nodes.get(id);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Clear renderer
|
|
388
|
+
*/
|
|
389
|
+
clear(): void {
|
|
390
|
+
this.rootNode = null;
|
|
391
|
+
this.nodes.clear();
|
|
392
|
+
this.eventManager.setRootNode(null);
|
|
393
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Register custom painter
|
|
398
|
+
*/
|
|
399
|
+
registerPainter(type: string, painter: PainterFunction): void {
|
|
400
|
+
registerPainter(type, painter);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Set renderer options
|
|
405
|
+
*/
|
|
406
|
+
setOptions(options: Partial<CanvasRendererOptions>): void {
|
|
407
|
+
this.options = { ...this.options, ...options };
|
|
408
|
+
this.accessibilityLayer.setEnabled(this.options.enableAccessibility || false);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Destroy renderer
|
|
413
|
+
*/
|
|
414
|
+
destroy(): void {
|
|
415
|
+
if (this.rafId !== null) {
|
|
416
|
+
cancelAnimationFrame(this.rafId);
|
|
417
|
+
}
|
|
418
|
+
this.eventManager.destroy();
|
|
419
|
+
this.accessibilityLayer.destroy();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text Rendering System
|
|
3
|
+
*
|
|
4
|
+
* Text measurement, wrapping, and rendering
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FontStyle, TextMetrics, TextStyle } from "./types.js";
|
|
8
|
+
import { createFontString } from "./utils.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Text metrics cache
|
|
12
|
+
*/
|
|
13
|
+
const textMetricsCache = new Map<string, TextMetrics>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get cache key for text metrics
|
|
17
|
+
*/
|
|
18
|
+
function getCacheKey(text: string, fontStyle: FontStyle, maxWidth?: number): string {
|
|
19
|
+
return `${text}|${fontStyle.fontSize}|${fontStyle.fontWeight}|${fontStyle.fontFamily}|${maxWidth || "auto"}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Measure text dimensions
|
|
24
|
+
*/
|
|
25
|
+
export function measureText(
|
|
26
|
+
ctx: CanvasRenderingContext2D,
|
|
27
|
+
text: string,
|
|
28
|
+
fontStyle: FontStyle,
|
|
29
|
+
maxWidth?: number
|
|
30
|
+
): TextMetrics {
|
|
31
|
+
const cacheKey = getCacheKey(text, fontStyle, maxWidth);
|
|
32
|
+
const cached = textMetricsCache.get(cacheKey);
|
|
33
|
+
if (cached) return cached;
|
|
34
|
+
|
|
35
|
+
const font = createFontString(fontStyle.fontSize, fontStyle.fontWeight, fontStyle.fontFamily);
|
|
36
|
+
ctx.save();
|
|
37
|
+
ctx.font = font;
|
|
38
|
+
|
|
39
|
+
const lineHeight = fontStyle.lineHeight || fontStyle.fontSize * 1.2;
|
|
40
|
+
|
|
41
|
+
// No wrapping needed
|
|
42
|
+
if (!maxWidth) {
|
|
43
|
+
const metrics = ctx.measureText(text);
|
|
44
|
+
const result: TextMetrics = {
|
|
45
|
+
width: metrics.width,
|
|
46
|
+
height: lineHeight,
|
|
47
|
+
lines: [text],
|
|
48
|
+
lineHeight,
|
|
49
|
+
};
|
|
50
|
+
ctx.restore();
|
|
51
|
+
textMetricsCache.set(cacheKey, result);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Wrap text
|
|
56
|
+
const lines = wrapText(ctx, text, maxWidth);
|
|
57
|
+
const width = Math.max(...lines.map((line) => ctx.measureText(line).width));
|
|
58
|
+
const height = lines.length * lineHeight;
|
|
59
|
+
|
|
60
|
+
const result: TextMetrics = {
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
lines,
|
|
64
|
+
lineHeight,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
ctx.restore();
|
|
68
|
+
textMetricsCache.set(cacheKey, result);
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Wrap text to fit within maxWidth
|
|
74
|
+
*/
|
|
75
|
+
function wrapText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number): string[] {
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
const paragraphs = text.split("\n");
|
|
78
|
+
|
|
79
|
+
for (const paragraph of paragraphs) {
|
|
80
|
+
const words = paragraph.split(" ");
|
|
81
|
+
let currentLine = "";
|
|
82
|
+
|
|
83
|
+
for (const word of words) {
|
|
84
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
85
|
+
const metrics = ctx.measureText(testLine);
|
|
86
|
+
|
|
87
|
+
if (metrics.width > maxWidth && currentLine) {
|
|
88
|
+
lines.push(currentLine);
|
|
89
|
+
currentLine = word;
|
|
90
|
+
} else {
|
|
91
|
+
currentLine = testLine;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (currentLine) {
|
|
96
|
+
lines.push(currentLine);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines.length > 0 ? lines : [""];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Render text with style
|
|
105
|
+
*/
|
|
106
|
+
export function renderText(
|
|
107
|
+
ctx: CanvasRenderingContext2D,
|
|
108
|
+
text: string,
|
|
109
|
+
x: number,
|
|
110
|
+
y: number,
|
|
111
|
+
width: number,
|
|
112
|
+
height: number,
|
|
113
|
+
style: TextStyle
|
|
114
|
+
): void {
|
|
115
|
+
const font = createFontString(style.fontSize, style.fontWeight, style.fontFamily);
|
|
116
|
+
ctx.save();
|
|
117
|
+
ctx.font = font;
|
|
118
|
+
ctx.fillStyle = style.color;
|
|
119
|
+
ctx.textBaseline = "top";
|
|
120
|
+
|
|
121
|
+
const metrics = measureText(ctx, text, style, width);
|
|
122
|
+
|
|
123
|
+
// Calculate starting Y based on vertical alignment
|
|
124
|
+
let startY = y;
|
|
125
|
+
if (style.verticalAlign === "middle") {
|
|
126
|
+
startY = y + (height - metrics.height) / 2;
|
|
127
|
+
} else if (style.verticalAlign === "bottom") {
|
|
128
|
+
startY = y + height - metrics.height;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Render each line
|
|
132
|
+
for (let i = 0; i < metrics.lines.length; i++) {
|
|
133
|
+
const line = metrics.lines[i];
|
|
134
|
+
const lineY = startY + i * metrics.lineHeight;
|
|
135
|
+
|
|
136
|
+
// Calculate X based on text alignment
|
|
137
|
+
let lineX = x;
|
|
138
|
+
if (style.textAlign === "center") {
|
|
139
|
+
const lineWidth = ctx.measureText(line).width;
|
|
140
|
+
lineX = x + (width - lineWidth) / 2;
|
|
141
|
+
} else if (style.textAlign === "right") {
|
|
142
|
+
const lineWidth = ctx.measureText(line).width;
|
|
143
|
+
lineX = x + width - lineWidth;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
ctx.fillText(line, lineX, lineY);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
ctx.restore();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clear text metrics cache
|
|
154
|
+
*/
|
|
155
|
+
export function clearTextCache(): void {
|
|
156
|
+
textMetricsCache.clear();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Preload font to ensure it's available
|
|
161
|
+
*/
|
|
162
|
+
export async function loadFont(fontFamily: string, fontWeight: string | number = "normal"): Promise<void> {
|
|
163
|
+
if (!("fonts" in document)) return;
|
|
164
|
+
|
|
165
|
+
const font = `${fontWeight} 16px ${fontFamily}`;
|
|
166
|
+
try {
|
|
167
|
+
await document.fonts.load(font);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
console.warn(`Failed to load font: ${font}`, error);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|