@hypen-space/web 0.4.46 → 0.4.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/canvas/dirty.d.ts +40 -0
- package/dist/canvas/dirty.js +100 -0
- package/dist/canvas/dirty.js.map +1 -0
- package/dist/canvas/events.d.ts +5 -0
- package/dist/canvas/events.js +30 -3
- package/dist/canvas/events.js.map +1 -1
- package/dist/canvas/index.d.ts +5 -1
- package/dist/canvas/index.js +4 -0
- package/dist/canvas/index.js.map +1 -1
- package/dist/canvas/layout.d.ts +12 -2
- package/dist/canvas/layout.js +593 -63
- package/dist/canvas/layout.js.map +1 -1
- package/dist/canvas/paint.d.ts +2 -0
- package/dist/canvas/paint.js +88 -11
- package/dist/canvas/paint.js.map +1 -1
- package/dist/canvas/renderer.d.ts +16 -0
- package/dist/canvas/renderer.js +154 -15
- package/dist/canvas/renderer.js.map +1 -1
- package/dist/canvas/scroll.d.ts +82 -0
- package/dist/canvas/scroll.js +639 -0
- package/dist/canvas/scroll.js.map +1 -0
- package/dist/canvas/selection.d.ts +63 -0
- package/dist/canvas/selection.js +466 -0
- package/dist/canvas/selection.js.map +1 -0
- package/dist/canvas/text.d.ts +6 -3
- package/dist/canvas/text.js +82 -43
- package/dist/canvas/text.js.map +1 -1
- package/dist/canvas/types.d.ts +18 -0
- package/dist/canvas/utils.d.ts +8 -2
- package/dist/canvas/utils.js +42 -16
- package/dist/canvas/utils.js.map +1 -1
- package/dist/canvas/virtualize.d.ts +25 -0
- package/dist/canvas/virtualize.js +65 -0
- package/dist/canvas/virtualize.js.map +1 -0
- package/dist/dom/applicators/index.js +0 -1
- package/dist/dom/applicators/index.js.map +1 -1
- package/dist/dom/applicators/margin.js +58 -17
- package/dist/dom/applicators/margin.js.map +1 -1
- package/dist/dom/applicators/padding.js +42 -17
- package/dist/dom/applicators/padding.js.map +1 -1
- package/dist/dom/components/icon.d.ts +9 -0
- package/dist/dom/components/icon.js +67 -0
- package/dist/dom/components/icon.js.map +1 -0
- package/dist/dom/components/index.js +2 -0
- package/dist/dom/components/index.js.map +1 -1
- package/dist/dom/renderer.d.ts +19 -5
- package/dist/dom/renderer.js +147 -17
- package/dist/dom/renderer.js.map +1 -1
- package/package.json +4 -2
- package/src/canvas/QUICKSTART.md +5 -5
- package/src/canvas/dirty.ts +116 -0
- package/src/canvas/events.ts +34 -3
- package/src/canvas/index.ts +5 -0
- package/src/canvas/layout.ts +653 -100
- package/src/canvas/paint.ts +109 -11
- package/src/canvas/renderer.ts +189 -16
- package/src/canvas/scroll.ts +729 -0
- package/src/canvas/selection.ts +560 -0
- package/src/canvas/text.ts +97 -54
- package/src/canvas/types.ts +26 -0
- package/src/canvas/utils.ts +47 -17
- package/src/canvas/virtualize.ts +81 -0
- package/src/dom/applicators/index.ts +1 -1
- package/src/dom/applicators/margin.ts +57 -11
- package/src/dom/applicators/padding.ts +45 -11
- package/src/dom/components/icon.ts +84 -0
- package/src/dom/components/index.ts +2 -0
- package/src/dom/renderer.ts +155 -17
package/src/canvas/paint.ts
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
|
|
7
7
|
import type { VirtualNode, PainterFunction } from "./types.js";
|
|
8
8
|
import { renderText } from "./text.js";
|
|
9
|
+
import { ScrollManager, isScrollable } from "./scroll.js";
|
|
10
|
+
import { getVisibleChildren, VIRTUALIZE_THRESHOLD } from "./virtualize.js";
|
|
11
|
+
import type { SelectionManager } from "./selection.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Module-level reference to the active SelectionManager so paintText
|
|
15
|
+
* can render selection highlights. Set once from the renderer.
|
|
16
|
+
*/
|
|
17
|
+
let activeSelectionManager: SelectionManager | null = null;
|
|
18
|
+
export function setSelectionManager(mgr: SelectionManager | null): void {
|
|
19
|
+
activeSelectionManager = mgr;
|
|
20
|
+
}
|
|
9
21
|
|
|
10
22
|
/**
|
|
11
23
|
* Custom painters registry
|
|
@@ -116,11 +128,44 @@ export function paintNode(ctx: CanvasRenderingContext2D, node: VirtualNode): voi
|
|
|
116
128
|
|
|
117
129
|
ctx.restore();
|
|
118
130
|
|
|
119
|
-
//
|
|
120
|
-
|
|
131
|
+
// Apply scroll translation for scrollable containers
|
|
132
|
+
const ss = node.scrollState;
|
|
133
|
+
if (ss && (ss.scrollX !== 0 || ss.scrollY !== 0)) {
|
|
134
|
+
ctx.save();
|
|
135
|
+
ctx.translate(-ss.scrollX, -ss.scrollY);
|
|
136
|
+
(node as any)._scrollRestore = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Paint children -- use windowed rendering for large scrollable lists
|
|
140
|
+
const shouldVirtualize =
|
|
141
|
+
isScrollable(node) &&
|
|
142
|
+
node.children.length > VIRTUALIZE_THRESHOLD &&
|
|
143
|
+
node.layout != null;
|
|
144
|
+
|
|
145
|
+
const childrenToPaint = shouldVirtualize
|
|
146
|
+
? getVisibleChildren(node, {
|
|
147
|
+
x: node.layout!.x,
|
|
148
|
+
y: node.layout!.y,
|
|
149
|
+
width: node.layout!.width,
|
|
150
|
+
height: node.layout!.height,
|
|
151
|
+
})
|
|
152
|
+
: node.children;
|
|
153
|
+
|
|
154
|
+
for (const child of childrenToPaint) {
|
|
121
155
|
paintNode(ctx, child);
|
|
122
156
|
}
|
|
123
157
|
|
|
158
|
+
// Undo scroll translation before drawing scrollbars
|
|
159
|
+
if ((node as any)._scrollRestore) {
|
|
160
|
+
ctx.restore();
|
|
161
|
+
delete (node as any)._scrollRestore;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Paint scrollbar indicators (on top of children, inside clip)
|
|
165
|
+
if (ss && ss.scrollbarOpacity > 0) {
|
|
166
|
+
ScrollManager.paintScrollbars(ctx, node);
|
|
167
|
+
}
|
|
168
|
+
|
|
124
169
|
// Restore context if overflow clipping was applied
|
|
125
170
|
if ((node as any)._needsRestore) {
|
|
126
171
|
ctx.restore();
|
|
@@ -206,6 +251,11 @@ function paintContainer(ctx: CanvasRenderingContext2D, node: VirtualNode): void
|
|
|
206
251
|
* Paint text node
|
|
207
252
|
*/
|
|
208
253
|
function paintText(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
254
|
+
// Draw selection highlight behind text
|
|
255
|
+
if (activeSelectionManager) {
|
|
256
|
+
activeSelectionManager.paintSelection(ctx, node);
|
|
257
|
+
}
|
|
258
|
+
|
|
209
259
|
const layout = node.layout!;
|
|
210
260
|
const props = node.props;
|
|
211
261
|
|
|
@@ -1217,7 +1267,8 @@ function paintAvatar(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1217
1267
|
}
|
|
1218
1268
|
|
|
1219
1269
|
/**
|
|
1220
|
-
* Paint icon node
|
|
1270
|
+
* Paint icon node using SVG path data from the engine's icon registry.
|
|
1271
|
+
* Falls back to simple shapes if no path data is provided.
|
|
1221
1272
|
*/
|
|
1222
1273
|
function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
1223
1274
|
const layout = node.layout!;
|
|
@@ -1226,25 +1277,71 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1226
1277
|
const size = Math.min(layout.width, layout.height);
|
|
1227
1278
|
const x = layout.x;
|
|
1228
1279
|
const y = layout.y;
|
|
1229
|
-
const color = props.color || "#000000";
|
|
1230
|
-
|
|
1280
|
+
const color = props.color || props["color.0"] || "#000000";
|
|
1281
|
+
|
|
1282
|
+
// Server-resolved SVG path data (from engine's ResourceRegistry)
|
|
1283
|
+
const iconPaths: Array<{
|
|
1284
|
+
d: string;
|
|
1285
|
+
fill?: string;
|
|
1286
|
+
stroke?: string;
|
|
1287
|
+
strokeWidth?: number;
|
|
1288
|
+
}> = props.__iconPaths;
|
|
1289
|
+
const viewBox: string = props.__iconViewBox || "0 0 24 24";
|
|
1290
|
+
|
|
1291
|
+
if (iconPaths && Array.isArray(iconPaths) && iconPaths.length > 0) {
|
|
1292
|
+
// Parse viewBox to compute scale
|
|
1293
|
+
const vbParts = viewBox.split(" ").map(Number);
|
|
1294
|
+
const vbWidth = vbParts[2] || 24;
|
|
1295
|
+
const vbHeight = vbParts[3] || 24;
|
|
1296
|
+
const scale = size / Math.max(vbWidth, vbHeight);
|
|
1297
|
+
|
|
1298
|
+
ctx.save();
|
|
1299
|
+
ctx.translate(x, y);
|
|
1300
|
+
ctx.scale(scale, scale);
|
|
1301
|
+
|
|
1302
|
+
for (const pathData of iconPaths) {
|
|
1303
|
+
const path2d = new Path2D(pathData.d);
|
|
1304
|
+
const fill = pathData.fill || "none";
|
|
1305
|
+
const stroke =
|
|
1306
|
+
(pathData.stroke || "currentColor") === "currentColor"
|
|
1307
|
+
? color
|
|
1308
|
+
: pathData.stroke || color;
|
|
1309
|
+
|
|
1310
|
+
if (fill && fill !== "none") {
|
|
1311
|
+
ctx.fillStyle = fill === "currentColor" ? color : fill;
|
|
1312
|
+
ctx.fill(path2d);
|
|
1313
|
+
}
|
|
1314
|
+
if (stroke && stroke !== "none") {
|
|
1315
|
+
ctx.strokeStyle = stroke;
|
|
1316
|
+
ctx.lineWidth = (pathData.strokeWidth || 2) ;
|
|
1317
|
+
ctx.lineCap = "round";
|
|
1318
|
+
ctx.lineJoin = "round";
|
|
1319
|
+
ctx.stroke(path2d);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1231
1322
|
|
|
1323
|
+
ctx.restore();
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Fallback: simple shapes for when no icon registry is available
|
|
1328
|
+
const iconName = props.icon || props.name || props["0"] || "circle";
|
|
1232
1329
|
ctx.fillStyle = color;
|
|
1233
1330
|
ctx.strokeStyle = color;
|
|
1234
1331
|
ctx.lineWidth = 2;
|
|
1235
1332
|
|
|
1236
|
-
|
|
1237
|
-
switch (iconName.toLowerCase()) {
|
|
1333
|
+
switch (String(iconName).toLowerCase()) {
|
|
1238
1334
|
case "circle":
|
|
1239
1335
|
ctx.beginPath();
|
|
1240
1336
|
ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
|
|
1241
1337
|
ctx.fill();
|
|
1242
1338
|
break;
|
|
1243
1339
|
|
|
1244
|
-
case "square":
|
|
1340
|
+
case "square": {
|
|
1245
1341
|
const padding = size * 0.2;
|
|
1246
1342
|
ctx.fillRect(x + padding, y + padding, size - padding * 2, size - padding * 2);
|
|
1247
1343
|
break;
|
|
1344
|
+
}
|
|
1248
1345
|
|
|
1249
1346
|
case "star":
|
|
1250
1347
|
drawStar(ctx, x + size / 2, y + size / 2, 5, size / 3, size / 6);
|
|
@@ -1252,7 +1349,7 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1252
1349
|
break;
|
|
1253
1350
|
|
|
1254
1351
|
case "check":
|
|
1255
|
-
case "checkmark":
|
|
1352
|
+
case "checkmark": {
|
|
1256
1353
|
const p = size * 0.2;
|
|
1257
1354
|
ctx.beginPath();
|
|
1258
1355
|
ctx.moveTo(x + p, y + size / 2);
|
|
@@ -1260,9 +1357,10 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1260
1357
|
ctx.lineTo(x + size - p, y + p);
|
|
1261
1358
|
ctx.stroke();
|
|
1262
1359
|
break;
|
|
1360
|
+
}
|
|
1263
1361
|
|
|
1264
1362
|
case "x":
|
|
1265
|
-
case "close":
|
|
1363
|
+
case "close": {
|
|
1266
1364
|
const pd = size * 0.2;
|
|
1267
1365
|
ctx.beginPath();
|
|
1268
1366
|
ctx.moveTo(x + pd, y + pd);
|
|
@@ -1271,9 +1369,9 @@ function paintIcon(ctx: CanvasRenderingContext2D, node: VirtualNode): void {
|
|
|
1271
1369
|
ctx.lineTo(x + pd, y + size - pd);
|
|
1272
1370
|
ctx.stroke();
|
|
1273
1371
|
break;
|
|
1372
|
+
}
|
|
1274
1373
|
|
|
1275
1374
|
default:
|
|
1276
|
-
// Default: filled circle
|
|
1277
1375
|
ctx.beginPath();
|
|
1278
1376
|
ctx.arc(x + size / 2, y + size / 2, size / 3, 0, Math.PI * 2);
|
|
1279
1377
|
ctx.fill();
|
package/src/canvas/renderer.ts
CHANGED
|
@@ -20,12 +20,16 @@ import type {
|
|
|
20
20
|
PainterFunction,
|
|
21
21
|
LayoutFunction,
|
|
22
22
|
} from "./types.js";
|
|
23
|
-
import { computeLayout } from "./layout.js";
|
|
23
|
+
import { computeLayout, initTaffyLayout } from "./layout.js";
|
|
24
24
|
import { paintNode, registerPainter } from "./paint.js";
|
|
25
25
|
import { CanvasEventManager } from "./events.js";
|
|
26
26
|
import { InputOverlay } from "./input.js";
|
|
27
27
|
import { AccessibilityLayer } from "./accessibility.js";
|
|
28
28
|
import { findNodeById } from "./utils.js";
|
|
29
|
+
import { ScrollManager } from "./scroll.js";
|
|
30
|
+
import { SelectionManager } from "./selection.js";
|
|
31
|
+
import { setSelectionManager } from "./paint.js";
|
|
32
|
+
import { DirtyRectTracker } from "./dirty.js";
|
|
29
33
|
|
|
30
34
|
const DEFAULT_OPTIONS: CanvasRendererOptions = {
|
|
31
35
|
devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1,
|
|
@@ -54,12 +58,16 @@ export class CanvasRenderer implements Renderer {
|
|
|
54
58
|
private nodes = new Map<string, VirtualNode>();
|
|
55
59
|
|
|
56
60
|
private eventManager: CanvasEventManager;
|
|
61
|
+
private scrollManager: ScrollManager;
|
|
62
|
+
private selectionManager: SelectionManager;
|
|
57
63
|
private inputOverlay: InputOverlay;
|
|
58
64
|
private accessibilityLayer: AccessibilityLayer;
|
|
59
65
|
|
|
66
|
+
private dirtyTracker: DirtyRectTracker;
|
|
67
|
+
|
|
60
68
|
private rafId: number | null = null;
|
|
61
69
|
private needsRedraw = false;
|
|
62
|
-
|
|
70
|
+
|
|
63
71
|
private frameCount = 0;
|
|
64
72
|
private lastFrameTime = 0;
|
|
65
73
|
|
|
@@ -78,8 +86,16 @@ export class CanvasRenderer implements Renderer {
|
|
|
78
86
|
// Setup HiDPI
|
|
79
87
|
this.setupHiDPI();
|
|
80
88
|
|
|
89
|
+
// Initialize dirty rect tracker
|
|
90
|
+
const dpr = this.options.devicePixelRatio || 1;
|
|
91
|
+
const rect = canvas.getBoundingClientRect();
|
|
92
|
+
this.dirtyTracker = new DirtyRectTracker(rect.width, rect.height);
|
|
93
|
+
|
|
81
94
|
// Initialize subsystems
|
|
82
95
|
this.eventManager = new CanvasEventManager(canvas, engine);
|
|
96
|
+
this.scrollManager = new ScrollManager(canvas, () => this.scheduleRedraw());
|
|
97
|
+
this.selectionManager = new SelectionManager(canvas, () => this.scheduleRedraw());
|
|
98
|
+
setSelectionManager(this.selectionManager);
|
|
83
99
|
this.inputOverlay = new InputOverlay(
|
|
84
100
|
(canvas as any).parentElement || (typeof document !== "undefined" ? document.body : null)
|
|
85
101
|
);
|
|
@@ -91,6 +107,9 @@ export class CanvasRenderer implements Renderer {
|
|
|
91
107
|
// Listen for redraw requests from event manager
|
|
92
108
|
this.canvas.addEventListener("hypen:redraw", () => this.scheduleRedraw());
|
|
93
109
|
|
|
110
|
+
// Eagerly initialise Taffy WASM for layout (non-blocking — fallback used until ready)
|
|
111
|
+
initTaffyLayout();
|
|
112
|
+
|
|
94
113
|
// Don't schedule initial render - wait for patches
|
|
95
114
|
}
|
|
96
115
|
|
|
@@ -109,16 +128,40 @@ export class CanvasRenderer implements Renderer {
|
|
|
109
128
|
// Update canvas display size
|
|
110
129
|
this.canvas.style.width = `${rect.width}px`;
|
|
111
130
|
this.canvas.style.height = `${rect.height}px`;
|
|
131
|
+
|
|
132
|
+
// Sync dirty tracker with new canvas size
|
|
133
|
+
if (this.dirtyTracker) {
|
|
134
|
+
this.dirtyTracker.setCanvasSize(rect.width, rect.height);
|
|
135
|
+
this.dirtyTracker.markFullDirty();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resize the canvas and re-run HiDPI setup + redraw.
|
|
141
|
+
* Called by DOMRenderer when the Canvas component's width/height props change.
|
|
142
|
+
*/
|
|
143
|
+
resize(width: number, height: number): void {
|
|
144
|
+
this.canvas.width = width;
|
|
145
|
+
this.canvas.height = height;
|
|
146
|
+
this.setupHiDPI();
|
|
147
|
+
this.scheduleRedraw();
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
/**
|
|
115
151
|
* Apply patches from engine
|
|
116
152
|
*/
|
|
117
153
|
applyPatches(patches: Patch[]): void {
|
|
154
|
+
const hadRoot = this.rootNode !== null;
|
|
155
|
+
|
|
118
156
|
for (const patch of patches) {
|
|
119
157
|
this.applyPatch(patch);
|
|
120
158
|
}
|
|
121
159
|
|
|
160
|
+
// On first render (root just appeared), mark the entire canvas dirty
|
|
161
|
+
if (this.options.enableDirtyRects && !hadRoot && this.rootNode !== null) {
|
|
162
|
+
this.dirtyTracker.markFullDirty();
|
|
163
|
+
}
|
|
164
|
+
|
|
122
165
|
// Update accessibility layer
|
|
123
166
|
if (this.rootNode) {
|
|
124
167
|
this.accessibilityLayer.syncTree(this.rootNode);
|
|
@@ -175,7 +218,7 @@ export class CanvasRenderer implements Renderer {
|
|
|
175
218
|
parent: null,
|
|
176
219
|
visible: true,
|
|
177
220
|
opacity: parseFloat(props.opacity) || 1,
|
|
178
|
-
clickable: elementType === "button" || !!props.onclick,
|
|
221
|
+
clickable: elementType === "button" || !!props.onclick || !!props.action,
|
|
179
222
|
hoverable: true,
|
|
180
223
|
focusable: elementType === "input" || elementType === "textarea" || elementType === "button",
|
|
181
224
|
focused: false,
|
|
@@ -192,6 +235,11 @@ export class CanvasRenderer implements Renderer {
|
|
|
192
235
|
const node = this.nodes.get(id);
|
|
193
236
|
if (!node) return;
|
|
194
237
|
|
|
238
|
+
// Mark dirty before prop change (old bounds)
|
|
239
|
+
if (this.options.enableDirtyRects) {
|
|
240
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
241
|
+
}
|
|
242
|
+
|
|
195
243
|
node.props[name] = value;
|
|
196
244
|
|
|
197
245
|
// Update computed properties
|
|
@@ -201,6 +249,14 @@ export class CanvasRenderer implements Renderer {
|
|
|
201
249
|
if (name === "opacity") {
|
|
202
250
|
node.opacity = parseFloat(value) || 1;
|
|
203
251
|
}
|
|
252
|
+
if (name === "action" || name === "onclick") {
|
|
253
|
+
node.clickable = !!value;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Mark dirty after prop change (new bounds will be captured after layout)
|
|
257
|
+
if (this.options.enableDirtyRects) {
|
|
258
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
259
|
+
}
|
|
204
260
|
|
|
205
261
|
// Update accessibility
|
|
206
262
|
this.accessibilityLayer.updateNode(node);
|
|
@@ -213,6 +269,10 @@ export class CanvasRenderer implements Renderer {
|
|
|
213
269
|
const node = this.nodes.get(id);
|
|
214
270
|
if (!node) return;
|
|
215
271
|
|
|
272
|
+
if (this.options.enableDirtyRects) {
|
|
273
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
274
|
+
}
|
|
275
|
+
|
|
216
276
|
delete node.props[name];
|
|
217
277
|
|
|
218
278
|
if (name === "visible") {
|
|
@@ -222,6 +282,10 @@ export class CanvasRenderer implements Renderer {
|
|
|
222
282
|
node.opacity = 1;
|
|
223
283
|
}
|
|
224
284
|
|
|
285
|
+
if (this.options.enableDirtyRects) {
|
|
286
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
287
|
+
}
|
|
288
|
+
|
|
225
289
|
this.accessibilityLayer.updateNode(node);
|
|
226
290
|
}
|
|
227
291
|
|
|
@@ -232,8 +296,16 @@ export class CanvasRenderer implements Renderer {
|
|
|
232
296
|
const node = this.nodes.get(id);
|
|
233
297
|
if (!node) return;
|
|
234
298
|
|
|
299
|
+
if (this.options.enableDirtyRects) {
|
|
300
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
301
|
+
}
|
|
302
|
+
|
|
235
303
|
node.props[0] = text;
|
|
236
304
|
|
|
305
|
+
if (this.options.enableDirtyRects) {
|
|
306
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
307
|
+
}
|
|
308
|
+
|
|
237
309
|
// Update accessibility
|
|
238
310
|
this.accessibilityLayer.updateNode(node);
|
|
239
311
|
}
|
|
@@ -249,6 +321,8 @@ export class CanvasRenderer implements Renderer {
|
|
|
249
321
|
if (parentId === "root" && id === "root") {
|
|
250
322
|
this.rootNode = child;
|
|
251
323
|
this.eventManager.setRootNode(child);
|
|
324
|
+
this.scrollManager.setRootNode(child);
|
|
325
|
+
this.selectionManager.setRootNode(child);
|
|
252
326
|
return;
|
|
253
327
|
}
|
|
254
328
|
|
|
@@ -259,6 +333,8 @@ export class CanvasRenderer implements Renderer {
|
|
|
259
333
|
if (parentId === "root") {
|
|
260
334
|
this.rootNode = child;
|
|
261
335
|
this.eventManager.setRootNode(child);
|
|
336
|
+
this.scrollManager.setRootNode(child);
|
|
337
|
+
this.selectionManager.setRootNode(child);
|
|
262
338
|
}
|
|
263
339
|
return;
|
|
264
340
|
}
|
|
@@ -276,6 +352,11 @@ export class CanvasRenderer implements Renderer {
|
|
|
276
352
|
} else {
|
|
277
353
|
parent.children.push(child);
|
|
278
354
|
}
|
|
355
|
+
|
|
356
|
+
// Mark parent dirty since its children changed
|
|
357
|
+
if (this.options.enableDirtyRects) {
|
|
358
|
+
this.dirtyTracker.markNodeDirty(parent);
|
|
359
|
+
}
|
|
279
360
|
}
|
|
280
361
|
|
|
281
362
|
/**
|
|
@@ -287,12 +368,19 @@ export class CanvasRenderer implements Renderer {
|
|
|
287
368
|
if (!node || !node.parent) return;
|
|
288
369
|
|
|
289
370
|
const oldParent = node.parent;
|
|
371
|
+
|
|
372
|
+
// Mark old position dirty before move
|
|
373
|
+
if (this.options.enableDirtyRects) {
|
|
374
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
375
|
+
this.dirtyTracker.markNodeDirty(oldParent);
|
|
376
|
+
}
|
|
377
|
+
|
|
290
378
|
const oldIndex = oldParent.children.indexOf(node);
|
|
291
379
|
if (oldIndex >= 0) {
|
|
292
380
|
oldParent.children.splice(oldIndex, 1);
|
|
293
381
|
}
|
|
294
382
|
|
|
295
|
-
// Insert into new location
|
|
383
|
+
// Insert into new location (also marks new parent dirty)
|
|
296
384
|
this.onInsert(parentId, id, beforeId);
|
|
297
385
|
}
|
|
298
386
|
|
|
@@ -303,6 +391,11 @@ export class CanvasRenderer implements Renderer {
|
|
|
303
391
|
const node = this.nodes.get(id);
|
|
304
392
|
if (!node) return;
|
|
305
393
|
|
|
394
|
+
// Mark dirty before removal
|
|
395
|
+
if (this.options.enableDirtyRects) {
|
|
396
|
+
this.dirtyTracker.markNodeDirty(node);
|
|
397
|
+
}
|
|
398
|
+
|
|
306
399
|
// Remove from parent
|
|
307
400
|
if (node.parent) {
|
|
308
401
|
const index = node.parent.children.indexOf(node);
|
|
@@ -315,6 +408,8 @@ export class CanvasRenderer implements Renderer {
|
|
|
315
408
|
if (this.rootNode === node) {
|
|
316
409
|
this.rootNode = null;
|
|
317
410
|
this.eventManager.setRootNode(null);
|
|
411
|
+
this.scrollManager.setRootNode(null);
|
|
412
|
+
this.selectionManager.setRootNode(null);
|
|
318
413
|
}
|
|
319
414
|
|
|
320
415
|
// Remove from nodes map
|
|
@@ -344,7 +439,30 @@ export class CanvasRenderer implements Renderer {
|
|
|
344
439
|
*/
|
|
345
440
|
private render(): void {
|
|
346
441
|
const startTime = performance.now();
|
|
442
|
+
const dpr = this.options.devicePixelRatio || 1;
|
|
443
|
+
|
|
444
|
+
if (this.options.enableDirtyRects) {
|
|
445
|
+
this.renderWithDirtyRects(dpr);
|
|
446
|
+
} else {
|
|
447
|
+
this.renderFull(dpr);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Performance logging
|
|
451
|
+
if (this.options.logPerformance) {
|
|
452
|
+
const elapsed = performance.now() - startTime;
|
|
453
|
+
this.frameCount++;
|
|
454
|
+
if (performance.now() - this.lastFrameTime > 1000) {
|
|
455
|
+
log.debug(`Canvas FPS: ${this.frameCount}, Last frame: ${elapsed.toFixed(2)}ms`);
|
|
456
|
+
this.frameCount = 0;
|
|
457
|
+
this.lastFrameTime = performance.now();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
347
461
|
|
|
462
|
+
/**
|
|
463
|
+
* Full canvas repaint (default behavior when dirty rects disabled)
|
|
464
|
+
*/
|
|
465
|
+
private renderFull(dpr: number): void {
|
|
348
466
|
// Clear canvas
|
|
349
467
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
350
468
|
|
|
@@ -356,8 +474,6 @@ export class CanvasRenderer implements Renderer {
|
|
|
356
474
|
|
|
357
475
|
// Layout and paint
|
|
358
476
|
if (this.rootNode) {
|
|
359
|
-
// Compute layout
|
|
360
|
-
const dpr = this.options.devicePixelRatio || 1;
|
|
361
477
|
computeLayout(
|
|
362
478
|
this.ctx,
|
|
363
479
|
this.rootNode,
|
|
@@ -365,25 +481,76 @@ export class CanvasRenderer implements Renderer {
|
|
|
365
481
|
this.canvas.height / dpr
|
|
366
482
|
);
|
|
367
483
|
|
|
368
|
-
|
|
484
|
+
ScrollManager.updateScrollBounds(this.rootNode);
|
|
485
|
+
|
|
369
486
|
paintNode(this.ctx, this.rootNode);
|
|
370
487
|
|
|
371
|
-
// Debug: show layout bounds
|
|
372
488
|
if (this.options.showLayoutBounds) {
|
|
373
489
|
this.drawLayoutBounds(this.rootNode);
|
|
374
490
|
}
|
|
375
491
|
}
|
|
492
|
+
}
|
|
376
493
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.
|
|
494
|
+
/**
|
|
495
|
+
* Optimized render that only repaints dirty regions
|
|
496
|
+
*/
|
|
497
|
+
private renderWithDirtyRects(dpr: number): void {
|
|
498
|
+
// Always run layout so nodes have up-to-date bounds
|
|
499
|
+
if (this.rootNode) {
|
|
500
|
+
computeLayout(
|
|
501
|
+
this.ctx,
|
|
502
|
+
this.rootNode,
|
|
503
|
+
this.canvas.width / dpr,
|
|
504
|
+
this.canvas.height / dpr
|
|
505
|
+
);
|
|
506
|
+
ScrollManager.updateScrollBounds(this.rootNode);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const dirtyRegion = this.dirtyTracker.getDirtyRegion();
|
|
510
|
+
this.dirtyTracker.clear();
|
|
511
|
+
|
|
512
|
+
// Nothing dirty — skip repaint entirely
|
|
513
|
+
if (dirtyRegion === null) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.ctx.save();
|
|
518
|
+
|
|
519
|
+
// Clip to dirty region so only affected pixels are drawn
|
|
520
|
+
this.ctx.beginPath();
|
|
521
|
+
this.ctx.rect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
|
|
522
|
+
this.ctx.clip();
|
|
523
|
+
|
|
524
|
+
// Clear only the dirty region
|
|
525
|
+
this.ctx.clearRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
|
|
526
|
+
|
|
527
|
+
// Repaint background within dirty region
|
|
528
|
+
if (this.options.backgroundColor) {
|
|
529
|
+
this.ctx.fillStyle = this.options.backgroundColor;
|
|
530
|
+
this.ctx.fillRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Repaint the full tree — clip path ensures only dirty pixels are touched
|
|
534
|
+
if (this.rootNode) {
|
|
535
|
+
paintNode(this.ctx, this.rootNode);
|
|
536
|
+
|
|
537
|
+
if (this.options.showLayoutBounds) {
|
|
538
|
+
this.drawLayoutBounds(this.rootNode);
|
|
385
539
|
}
|
|
386
540
|
}
|
|
541
|
+
|
|
542
|
+
this.ctx.restore();
|
|
543
|
+
|
|
544
|
+
// Debug: show dirty rect outline
|
|
545
|
+
if (this.options.showDirtyRects) {
|
|
546
|
+
this.ctx.save();
|
|
547
|
+
this.ctx.strokeStyle = "rgba(255, 0, 0, 0.8)";
|
|
548
|
+
this.ctx.lineWidth = 2;
|
|
549
|
+
this.ctx.setLineDash([4, 2]);
|
|
550
|
+
this.ctx.strokeRect(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
|
|
551
|
+
this.ctx.setLineDash([]);
|
|
552
|
+
this.ctx.restore();
|
|
553
|
+
}
|
|
387
554
|
}
|
|
388
555
|
|
|
389
556
|
/**
|
|
@@ -417,6 +584,9 @@ export class CanvasRenderer implements Renderer {
|
|
|
417
584
|
this.rootNode = null;
|
|
418
585
|
this.nodes.clear();
|
|
419
586
|
this.eventManager.setRootNode(null);
|
|
587
|
+
this.scrollManager.setRootNode(null);
|
|
588
|
+
this.selectionManager.setRootNode(null);
|
|
589
|
+
this.dirtyTracker.clear();
|
|
420
590
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
421
591
|
}
|
|
422
592
|
|
|
@@ -443,6 +613,9 @@ export class CanvasRenderer implements Renderer {
|
|
|
443
613
|
cancelAnimationFrame(this.rafId);
|
|
444
614
|
}
|
|
445
615
|
this.eventManager.destroy();
|
|
616
|
+
this.scrollManager.destroy();
|
|
617
|
+
this.selectionManager.destroy();
|
|
618
|
+
setSelectionManager(null);
|
|
446
619
|
this.accessibilityLayer.destroy();
|
|
447
620
|
}
|
|
448
621
|
}
|