@zseven-w/pen-renderer 0.6.0 → 0.7.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/README.md +6 -6
- package/package.json +9 -9
- package/src/__tests__/document-flattener.test.ts +166 -90
- package/src/__tests__/font-manager.test.ts +65 -0
- package/src/__tests__/image-loader.test.ts +136 -0
- package/src/__tests__/paint-utils.test.ts +61 -0
- package/src/__tests__/render-node-thumbnail.test.ts +312 -0
- package/src/document-flattener.ts +222 -159
- package/src/font-manager.ts +221 -190
- package/src/image-loader.ts +138 -51
- package/src/index.ts +18 -17
- package/src/init.ts +50 -21
- package/src/node-renderer.ts +957 -386
- package/src/paint-utils.ts +99 -74
- package/src/path-utils.ts +235 -115
- package/src/render-node-thumbnail.ts +155 -0
- package/src/renderer.ts +196 -175
- package/src/spatial-index.ts +139 -27
- package/src/text-renderer.ts +360 -302
- package/src/types.ts +18 -22
- package/src/viewport.ts +28 -29
package/src/renderer.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CanvasKit, Surface } from 'canvaskit-wasm'
|
|
2
|
-
import type { PenDocument, PenNode } from '@zseven-w/pen-types'
|
|
1
|
+
import type { CanvasKit, Surface } from 'canvaskit-wasm';
|
|
2
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
3
3
|
import {
|
|
4
4
|
getActivePageChildren,
|
|
5
5
|
getAllChildren,
|
|
@@ -12,23 +12,19 @@ import {
|
|
|
12
12
|
FRAME_LABEL_OFFSET_Y,
|
|
13
13
|
FRAME_LABEL_COLOR,
|
|
14
14
|
setRootChildrenProvider,
|
|
15
|
-
} from '@zseven-w/pen-core'
|
|
16
|
-
import { SkiaNodeRenderer } from './node-renderer.js'
|
|
17
|
-
import { SpatialIndex } from './spatial-index.js'
|
|
15
|
+
} from '@zseven-w/pen-core';
|
|
16
|
+
import { SkiaNodeRenderer } from './node-renderer.js';
|
|
17
|
+
import { SpatialIndex } from './spatial-index.js';
|
|
18
18
|
import {
|
|
19
19
|
flattenToRenderNodes,
|
|
20
20
|
resolveRefs,
|
|
21
21
|
premeasureTextHeights,
|
|
22
22
|
collectReusableIds,
|
|
23
23
|
collectInstanceIds,
|
|
24
|
-
} from './document-flattener.js'
|
|
25
|
-
import { parseColor } from './paint-utils.js'
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
screenToScene,
|
|
29
|
-
zoomToPoint as vpZoomToPoint,
|
|
30
|
-
} from './viewport.js'
|
|
31
|
-
import type { RenderNode, PenRendererOptions, ViewportState } from './types.js'
|
|
24
|
+
} from './document-flattener.js';
|
|
25
|
+
import { parseColor } from './paint-utils.js';
|
|
26
|
+
import { viewportMatrix, screenToScene, zoomToPoint as vpZoomToPoint } from './viewport.js';
|
|
27
|
+
import type { RenderNode, PenRendererOptions, ViewportState } from './types.js';
|
|
32
28
|
|
|
33
29
|
/**
|
|
34
30
|
* Standalone read-only renderer for OpenPencil (.op) design files.
|
|
@@ -46,41 +42,41 @@ import type { RenderNode, PenRendererOptions, ViewportState } from './types.js'
|
|
|
46
42
|
* ```
|
|
47
43
|
*/
|
|
48
44
|
export class PenRenderer {
|
|
49
|
-
private ck: CanvasKit
|
|
50
|
-
private surface: Surface | null = null
|
|
51
|
-
private canvasEl: HTMLCanvasElement | null = null
|
|
52
|
-
private nodeRenderer: SkiaNodeRenderer
|
|
53
|
-
private spatialIndex = new SpatialIndex()
|
|
54
|
-
private renderNodes: RenderNode[] = []
|
|
55
|
-
private options: PenRendererOptions
|
|
45
|
+
private ck: CanvasKit;
|
|
46
|
+
private surface: Surface | null = null;
|
|
47
|
+
private canvasEl: HTMLCanvasElement | null = null;
|
|
48
|
+
private nodeRenderer: SkiaNodeRenderer;
|
|
49
|
+
private spatialIndex = new SpatialIndex();
|
|
50
|
+
private renderNodes: RenderNode[] = [];
|
|
51
|
+
private options: PenRendererOptions;
|
|
56
52
|
|
|
57
53
|
// Component/instance IDs for colored frame labels
|
|
58
|
-
private reusableIds = new Set<string>()
|
|
59
|
-
private instanceIds = new Set<string>()
|
|
54
|
+
private reusableIds = new Set<string>();
|
|
55
|
+
private instanceIds = new Set<string>();
|
|
60
56
|
|
|
61
57
|
// Viewport
|
|
62
|
-
private _zoom = 1
|
|
63
|
-
private _panX = 0
|
|
64
|
-
private _panY = 0
|
|
65
|
-
private dirty = true
|
|
66
|
-
private animFrameId = 0
|
|
58
|
+
private _zoom = 1;
|
|
59
|
+
private _panX = 0;
|
|
60
|
+
private _panY = 0;
|
|
61
|
+
private dirty = true;
|
|
62
|
+
private animFrameId = 0;
|
|
67
63
|
|
|
68
64
|
// Document
|
|
69
|
-
private document: PenDocument | null = null
|
|
70
|
-
private activePageId: string | null = null
|
|
65
|
+
private document: PenDocument | null = null;
|
|
66
|
+
private activePageId: string | null = null;
|
|
71
67
|
|
|
72
68
|
constructor(ck: CanvasKit, options?: PenRendererOptions) {
|
|
73
|
-
this.ck = ck
|
|
74
|
-
this.options = options ?? {}
|
|
69
|
+
this.ck = ck;
|
|
70
|
+
this.options = options ?? {};
|
|
75
71
|
this.nodeRenderer = new SkiaNodeRenderer(ck, {
|
|
76
72
|
fontBasePath: this.options.fontBasePath,
|
|
77
73
|
googleFontsCssUrl: this.options.googleFontsCssUrl,
|
|
78
|
-
})
|
|
74
|
+
});
|
|
79
75
|
if (this.options.iconLookup) {
|
|
80
|
-
this.nodeRenderer.setIconLookup(this.options.iconLookup)
|
|
76
|
+
this.nodeRenderer.setIconLookup(this.options.iconLookup);
|
|
81
77
|
}
|
|
82
78
|
if (this.options.devicePixelRatio !== undefined) {
|
|
83
|
-
this.nodeRenderer.devicePixelRatio = this.options.devicePixelRatio
|
|
79
|
+
this.nodeRenderer.devicePixelRatio = this.options.devicePixelRatio;
|
|
84
80
|
}
|
|
85
81
|
}
|
|
86
82
|
|
|
@@ -89,47 +85,50 @@ export class PenRenderer {
|
|
|
89
85
|
// ---------------------------------------------------------------------------
|
|
90
86
|
|
|
91
87
|
init(canvas: HTMLCanvasElement) {
|
|
92
|
-
this.canvasEl = canvas
|
|
93
|
-
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
|
|
94
|
-
canvas.width = canvas.clientWidth * dpr
|
|
95
|
-
canvas.height = canvas.clientHeight * dpr
|
|
96
|
-
|
|
97
|
-
this.surface = this.ck.MakeWebGLCanvasSurface(canvas)
|
|
98
|
-
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(canvas)
|
|
99
|
-
if (!this.surface) {
|
|
88
|
+
this.canvasEl = canvas;
|
|
89
|
+
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
90
|
+
canvas.width = canvas.clientWidth * dpr;
|
|
91
|
+
canvas.height = canvas.clientHeight * dpr;
|
|
92
|
+
|
|
93
|
+
this.surface = this.ck.MakeWebGLCanvasSurface(canvas);
|
|
94
|
+
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(canvas);
|
|
95
|
+
if (!this.surface) {
|
|
96
|
+
console.error('PenRenderer: Failed to create surface');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
100
99
|
|
|
101
|
-
this.nodeRenderer.init()
|
|
102
|
-
this.nodeRenderer.setRedrawCallback(() => this.markDirty())
|
|
103
|
-
|
|
100
|
+
this.nodeRenderer.init();
|
|
101
|
+
this.nodeRenderer.setRedrawCallback(() => this.markDirty());
|
|
102
|
+
(this.nodeRenderer as any).textRenderer._onFontLoaded = () => this.markDirty();
|
|
104
103
|
|
|
105
104
|
// Pre-load default fonts
|
|
106
|
-
const defaultFonts = this.options.defaultFonts ?? ['Inter', 'Noto Sans SC']
|
|
105
|
+
const defaultFonts = this.options.defaultFonts ?? ['Inter', 'Noto Sans SC'];
|
|
107
106
|
for (const font of defaultFonts) {
|
|
108
|
-
this.nodeRenderer.fontManager.ensureFont(font).then(() => this.markDirty())
|
|
107
|
+
this.nodeRenderer.fontManager.ensureFont(font).then(() => this.markDirty());
|
|
109
108
|
}
|
|
110
109
|
|
|
111
110
|
// Wire up root children provider for layout engine fill-width fallback
|
|
112
|
-
setRootChildrenProvider(() => this.document?.children ?? [])
|
|
111
|
+
setRootChildrenProvider(() => this.document?.children ?? []);
|
|
113
112
|
|
|
114
|
-
this.startRenderLoop()
|
|
113
|
+
this.startRenderLoop();
|
|
115
114
|
}
|
|
116
115
|
|
|
117
116
|
dispose() {
|
|
118
|
-
if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
|
|
119
|
-
this.nodeRenderer.dispose()
|
|
120
|
-
this.surface?.delete()
|
|
121
|
-
this.surface = null
|
|
117
|
+
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
118
|
+
this.nodeRenderer.dispose();
|
|
119
|
+
this.surface?.delete();
|
|
120
|
+
this.surface = null;
|
|
122
121
|
}
|
|
123
122
|
|
|
124
123
|
resize(width: number, height: number) {
|
|
125
|
-
if (!this.canvasEl) return
|
|
126
|
-
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
|
|
127
|
-
this.canvasEl.width = width * dpr
|
|
128
|
-
this.canvasEl.height = height * dpr
|
|
129
|
-
this.surface?.delete()
|
|
130
|
-
this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl)
|
|
131
|
-
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl)
|
|
132
|
-
this.markDirty()
|
|
124
|
+
if (!this.canvasEl) return;
|
|
125
|
+
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
126
|
+
this.canvasEl.width = width * dpr;
|
|
127
|
+
this.canvasEl.height = height * dpr;
|
|
128
|
+
this.surface?.delete();
|
|
129
|
+
this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl);
|
|
130
|
+
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl);
|
|
131
|
+
this.markDirty();
|
|
133
132
|
}
|
|
134
133
|
|
|
135
134
|
// ---------------------------------------------------------------------------
|
|
@@ -137,13 +136,13 @@ export class PenRenderer {
|
|
|
137
136
|
// ---------------------------------------------------------------------------
|
|
138
137
|
|
|
139
138
|
setDocument(doc: PenDocument) {
|
|
140
|
-
this.document = doc
|
|
141
|
-
this.activePageId = doc.pages?.[0]?.id ?? null
|
|
142
|
-
this.syncFromDocument()
|
|
139
|
+
this.document = doc;
|
|
140
|
+
this.activePageId = doc.pages?.[0]?.id ?? null;
|
|
141
|
+
this.syncFromDocument();
|
|
143
142
|
}
|
|
144
143
|
|
|
145
144
|
getDocument(): PenDocument | null {
|
|
146
|
-
return this.document
|
|
145
|
+
return this.document;
|
|
147
146
|
}
|
|
148
147
|
|
|
149
148
|
// ---------------------------------------------------------------------------
|
|
@@ -151,16 +150,16 @@ export class PenRenderer {
|
|
|
151
150
|
// ---------------------------------------------------------------------------
|
|
152
151
|
|
|
153
152
|
setPage(pageId: string) {
|
|
154
|
-
this.activePageId = pageId
|
|
155
|
-
this.syncFromDocument()
|
|
153
|
+
this.activePageId = pageId;
|
|
154
|
+
this.syncFromDocument();
|
|
156
155
|
}
|
|
157
156
|
|
|
158
157
|
getPageIds(): string[] {
|
|
159
|
-
return this.document?.pages?.map(p => p.id) ?? []
|
|
158
|
+
return this.document?.pages?.map((p) => p.id) ?? [];
|
|
160
159
|
}
|
|
161
160
|
|
|
162
161
|
getActivePageId(): string | null {
|
|
163
|
-
return this.activePageId
|
|
162
|
+
return this.activePageId;
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
// ---------------------------------------------------------------------------
|
|
@@ -168,54 +167,60 @@ export class PenRenderer {
|
|
|
168
167
|
// ---------------------------------------------------------------------------
|
|
169
168
|
|
|
170
169
|
setViewport(zoom: number, panX: number, panY: number) {
|
|
171
|
-
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom))
|
|
172
|
-
this._panX = panX
|
|
173
|
-
this._panY = panY
|
|
174
|
-
this.markDirty()
|
|
170
|
+
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
|
171
|
+
this._panX = panX;
|
|
172
|
+
this._panY = panY;
|
|
173
|
+
this.markDirty();
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
getViewport(): ViewportState {
|
|
178
|
-
return { zoom: this._zoom, panX: this._panX, panY: this._panY }
|
|
177
|
+
return { zoom: this._zoom, panX: this._panX, panY: this._panY };
|
|
179
178
|
}
|
|
180
179
|
|
|
181
180
|
zoomToFit(padding = 64) {
|
|
182
|
-
if (!this.canvasEl || this.renderNodes.length === 0) return
|
|
183
|
-
let minX = Infinity,
|
|
181
|
+
if (!this.canvasEl || this.renderNodes.length === 0) return;
|
|
182
|
+
let minX = Infinity,
|
|
183
|
+
minY = Infinity,
|
|
184
|
+
maxX = -Infinity,
|
|
185
|
+
maxY = -Infinity;
|
|
184
186
|
for (const rn of this.renderNodes) {
|
|
185
|
-
if (rn.clipRect) continue
|
|
186
|
-
minX = Math.min(minX, rn.absX)
|
|
187
|
-
minY = Math.min(minY, rn.absY)
|
|
188
|
-
maxX = Math.max(maxX, rn.absX + rn.absW)
|
|
189
|
-
maxY = Math.max(maxY, rn.absY + rn.absH)
|
|
187
|
+
if (rn.clipRect) continue;
|
|
188
|
+
minX = Math.min(minX, rn.absX);
|
|
189
|
+
minY = Math.min(minY, rn.absY);
|
|
190
|
+
maxX = Math.max(maxX, rn.absX + rn.absW);
|
|
191
|
+
maxY = Math.max(maxY, rn.absY + rn.absH);
|
|
190
192
|
}
|
|
191
|
-
if (!isFinite(minX)) return
|
|
193
|
+
if (!isFinite(minX)) return;
|
|
192
194
|
|
|
193
|
-
const contentW = maxX - minX
|
|
194
|
-
const contentH = maxY - minY
|
|
195
|
-
const canvasW = this.canvasEl.clientWidth
|
|
196
|
-
const canvasH = this.canvasEl.clientHeight
|
|
195
|
+
const contentW = maxX - minX;
|
|
196
|
+
const contentH = maxY - minY;
|
|
197
|
+
const canvasW = this.canvasEl.clientWidth;
|
|
198
|
+
const canvasH = this.canvasEl.clientHeight;
|
|
197
199
|
const zoom = Math.min(
|
|
198
200
|
(canvasW - padding * 2) / contentW,
|
|
199
201
|
(canvasH - padding * 2) / contentH,
|
|
200
202
|
2,
|
|
201
|
-
)
|
|
202
|
-
const panX = (canvasW - contentW * zoom) / 2 - minX * zoom
|
|
203
|
-
const panY = (canvasH - contentH * zoom) / 2 - minY * zoom
|
|
204
|
-
this.setViewport(zoom, panX, panY)
|
|
203
|
+
);
|
|
204
|
+
const panX = (canvasW - contentW * zoom) / 2 - minX * zoom;
|
|
205
|
+
const panY = (canvasH - contentH * zoom) / 2 - minY * zoom;
|
|
206
|
+
this.setViewport(zoom, panX, panY);
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
zoomToPoint(screenX: number, screenY: number, newZoom: number) {
|
|
208
|
-
if (!this.canvasEl) return
|
|
209
|
-
const rect = this.canvasEl.getBoundingClientRect()
|
|
210
|
+
if (!this.canvasEl) return;
|
|
211
|
+
const rect = this.canvasEl.getBoundingClientRect();
|
|
210
212
|
const vp = vpZoomToPoint(
|
|
211
213
|
{ zoom: this._zoom, panX: this._panX, panY: this._panY },
|
|
212
|
-
screenX,
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
screenX,
|
|
215
|
+
screenY,
|
|
216
|
+
rect,
|
|
217
|
+
newZoom,
|
|
218
|
+
);
|
|
219
|
+
this.setViewport(vp.zoom, vp.panX, vp.panY);
|
|
215
220
|
}
|
|
216
221
|
|
|
217
222
|
pan(dx: number, dy: number) {
|
|
218
|
-
this.setViewport(this._zoom, this._panX + dx, this._panY + dy)
|
|
223
|
+
this.setViewport(this._zoom, this._panX + dx, this._panY + dy);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
// ---------------------------------------------------------------------------
|
|
@@ -223,8 +228,8 @@ export class PenRenderer {
|
|
|
223
228
|
// ---------------------------------------------------------------------------
|
|
224
229
|
|
|
225
230
|
setThemeVariant(variant: Record<string, string>) {
|
|
226
|
-
this.options.themeVariant = variant
|
|
227
|
-
this.syncFromDocument()
|
|
231
|
+
this.options.themeVariant = variant;
|
|
232
|
+
this.syncFromDocument();
|
|
228
233
|
}
|
|
229
234
|
|
|
230
235
|
// ---------------------------------------------------------------------------
|
|
@@ -232,17 +237,21 @@ export class PenRenderer {
|
|
|
232
237
|
// ---------------------------------------------------------------------------
|
|
233
238
|
|
|
234
239
|
hitTest(screenX: number, screenY: number): PenNode | null {
|
|
235
|
-
if (!this.canvasEl) return null
|
|
236
|
-
const rect = this.canvasEl.getBoundingClientRect()
|
|
237
|
-
const scene = screenToScene(screenX, screenY, rect, {
|
|
238
|
-
|
|
239
|
-
|
|
240
|
+
if (!this.canvasEl) return null;
|
|
241
|
+
const rect = this.canvasEl.getBoundingClientRect();
|
|
242
|
+
const scene = screenToScene(screenX, screenY, rect, {
|
|
243
|
+
zoom: this._zoom,
|
|
244
|
+
panX: this._panX,
|
|
245
|
+
panY: this._panY,
|
|
246
|
+
});
|
|
247
|
+
const hits = this.spatialIndex.hitTest(scene.x, scene.y);
|
|
248
|
+
return hits.length > 0 ? hits[0].node : null;
|
|
240
249
|
}
|
|
241
250
|
|
|
242
251
|
getNodeBounds(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
|
243
|
-
const rn = this.spatialIndex.get(nodeId)
|
|
244
|
-
if (!rn) return null
|
|
245
|
-
return { x: rn.absX, y: rn.absY, w: rn.absW, h: rn.absH }
|
|
252
|
+
const rn = this.spatialIndex.get(nodeId);
|
|
253
|
+
if (!rn) return null;
|
|
254
|
+
return { x: rn.absX, y: rn.absY, w: rn.absW, h: rn.absH };
|
|
246
255
|
}
|
|
247
256
|
|
|
248
257
|
// ---------------------------------------------------------------------------
|
|
@@ -250,31 +259,31 @@ export class PenRenderer {
|
|
|
250
259
|
// ---------------------------------------------------------------------------
|
|
251
260
|
|
|
252
261
|
private syncFromDocument() {
|
|
253
|
-
if (!this.document) return
|
|
254
|
-
const pageChildren = getActivePageChildren(this.document, this.activePageId)
|
|
255
|
-
const allNodes = getAllChildren(this.document)
|
|
262
|
+
if (!this.document) return;
|
|
263
|
+
const pageChildren = getActivePageChildren(this.document, this.activePageId);
|
|
264
|
+
const allNodes = getAllChildren(this.document);
|
|
256
265
|
|
|
257
266
|
// Collect reusable/instance IDs
|
|
258
|
-
this.reusableIds.clear()
|
|
259
|
-
this.instanceIds.clear()
|
|
260
|
-
collectReusableIds(pageChildren, this.reusableIds)
|
|
261
|
-
collectInstanceIds(pageChildren, this.instanceIds)
|
|
267
|
+
this.reusableIds.clear();
|
|
268
|
+
this.instanceIds.clear();
|
|
269
|
+
collectReusableIds(pageChildren, this.reusableIds);
|
|
270
|
+
collectInstanceIds(pageChildren, this.instanceIds);
|
|
262
271
|
|
|
263
272
|
// Resolve refs
|
|
264
|
-
const resolved = resolveRefs(pageChildren, allNodes)
|
|
273
|
+
const resolved = resolveRefs(pageChildren, allNodes);
|
|
265
274
|
|
|
266
275
|
// Resolve design variables
|
|
267
|
-
const variables = this.document.variables ?? {}
|
|
268
|
-
const themes = this.document.themes
|
|
269
|
-
const activeTheme = this.options.themeVariant ?? getDefaultTheme(themes)
|
|
270
|
-
const variableResolved = resolved.map((n) => resolveNodeForCanvas(n, variables, activeTheme))
|
|
276
|
+
const variables = this.document.variables ?? {};
|
|
277
|
+
const themes = this.document.themes;
|
|
278
|
+
const activeTheme = this.options.themeVariant ?? getDefaultTheme(themes);
|
|
279
|
+
const variableResolved = resolved.map((n) => resolveNodeForCanvas(n, variables, activeTheme));
|
|
271
280
|
|
|
272
281
|
// Pre-measure text heights
|
|
273
|
-
const measured = premeasureTextHeights(variableResolved)
|
|
282
|
+
const measured = premeasureTextHeights(variableResolved);
|
|
274
283
|
|
|
275
|
-
this.renderNodes = flattenToRenderNodes(measured)
|
|
276
|
-
this.spatialIndex.rebuild(this.renderNodes)
|
|
277
|
-
this.markDirty()
|
|
284
|
+
this.renderNodes = flattenToRenderNodes(measured);
|
|
285
|
+
this.spatialIndex.rebuild(this.renderNodes);
|
|
286
|
+
this.markDirty();
|
|
278
287
|
}
|
|
279
288
|
|
|
280
289
|
// ---------------------------------------------------------------------------
|
|
@@ -282,93 +291,105 @@ export class PenRenderer {
|
|
|
282
291
|
// ---------------------------------------------------------------------------
|
|
283
292
|
|
|
284
293
|
private markDirty() {
|
|
285
|
-
this.dirty = true
|
|
294
|
+
this.dirty = true;
|
|
286
295
|
}
|
|
287
296
|
|
|
288
297
|
private startRenderLoop() {
|
|
289
298
|
const loop = () => {
|
|
290
|
-
this.animFrameId = requestAnimationFrame(loop)
|
|
291
|
-
if (!this.dirty || !this.surface) return
|
|
292
|
-
this.dirty = false
|
|
293
|
-
this.render()
|
|
294
|
-
}
|
|
295
|
-
this.animFrameId = requestAnimationFrame(loop)
|
|
299
|
+
this.animFrameId = requestAnimationFrame(loop);
|
|
300
|
+
if (!this.dirty || !this.surface) return;
|
|
301
|
+
this.dirty = false;
|
|
302
|
+
this.render();
|
|
303
|
+
};
|
|
304
|
+
this.animFrameId = requestAnimationFrame(loop);
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
render() {
|
|
299
|
-
if (!this.surface || !this.canvasEl) return
|
|
300
|
-
const canvas = this.surface.getCanvas()
|
|
301
|
-
const ck = this.ck
|
|
302
|
-
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
|
|
308
|
+
if (!this.surface || !this.canvasEl) return;
|
|
309
|
+
const canvas = this.surface.getCanvas();
|
|
310
|
+
const ck = this.ck;
|
|
311
|
+
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
303
312
|
|
|
304
313
|
// Clear
|
|
305
|
-
const bgColor = this.options.backgroundColor ?? CANVAS_BACKGROUND_DARK
|
|
306
|
-
canvas.clear(parseColor(ck, bgColor))
|
|
314
|
+
const bgColor = this.options.backgroundColor ?? CANVAS_BACKGROUND_DARK;
|
|
315
|
+
canvas.clear(parseColor(ck, bgColor));
|
|
307
316
|
|
|
308
317
|
// Apply viewport transform
|
|
309
|
-
canvas.save()
|
|
310
|
-
canvas.scale(dpr, dpr)
|
|
311
|
-
canvas.concat(viewportMatrix({ zoom: this._zoom, panX: this._panX, panY: this._panY }))
|
|
318
|
+
canvas.save();
|
|
319
|
+
canvas.scale(dpr, dpr);
|
|
320
|
+
canvas.concat(viewportMatrix({ zoom: this._zoom, panX: this._panX, panY: this._panY }));
|
|
312
321
|
|
|
313
322
|
// Pass current zoom to renderer
|
|
314
|
-
this.nodeRenderer.zoom = this._zoom
|
|
323
|
+
this.nodeRenderer.zoom = this._zoom;
|
|
315
324
|
|
|
316
325
|
// Draw all render nodes
|
|
317
326
|
for (const rn of this.renderNodes) {
|
|
318
|
-
this.nodeRenderer.drawNode(canvas, rn)
|
|
327
|
+
this.nodeRenderer.drawNode(canvas, rn);
|
|
319
328
|
}
|
|
320
329
|
|
|
321
330
|
// Draw frame labels for root frames + reusable + instances
|
|
322
331
|
for (const rn of this.renderNodes) {
|
|
323
|
-
if (!rn.node.name) continue
|
|
324
|
-
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect
|
|
325
|
-
const isReusable = this.reusableIds.has(rn.node.id)
|
|
326
|
-
const isInstance = this.instanceIds.has(rn.node.id)
|
|
327
|
-
if (!isRootFrame && !isReusable && !isInstance) continue
|
|
328
|
-
this.drawFrameLabel(canvas, rn.node.name, rn.absX, rn.absY)
|
|
332
|
+
if (!rn.node.name) continue;
|
|
333
|
+
const isRootFrame = rn.node.type === 'frame' && !rn.clipRect;
|
|
334
|
+
const isReusable = this.reusableIds.has(rn.node.id);
|
|
335
|
+
const isInstance = this.instanceIds.has(rn.node.id);
|
|
336
|
+
if (!isRootFrame && !isReusable && !isInstance) continue;
|
|
337
|
+
this.drawFrameLabel(canvas, rn.node.name, rn.absX, rn.absY);
|
|
329
338
|
}
|
|
330
339
|
|
|
331
|
-
canvas.restore()
|
|
332
|
-
this.surface.flush()
|
|
340
|
+
canvas.restore();
|
|
341
|
+
this.surface.flush();
|
|
333
342
|
}
|
|
334
343
|
|
|
335
344
|
/** Simple frame label drawing for read-only renderer. */
|
|
336
|
-
private drawFrameLabel(
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
345
|
+
private drawFrameLabel(
|
|
346
|
+
canvas: ReturnType<Surface['getCanvas']>,
|
|
347
|
+
name: string,
|
|
348
|
+
x: number,
|
|
349
|
+
y: number,
|
|
350
|
+
) {
|
|
351
|
+
const ck = this.ck;
|
|
352
|
+
const fontSize = FRAME_LABEL_FONT_SIZE / this._zoom;
|
|
353
|
+
const offsetY = FRAME_LABEL_OFFSET_Y / this._zoom;
|
|
340
354
|
|
|
341
355
|
// Use Canvas 2D to rasterize the label text
|
|
342
|
-
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1
|
|
343
|
-
const scale = Math.min(this._zoom * dpr, 4)
|
|
344
|
-
const tmp = document.createElement('canvas')
|
|
345
|
-
const textW = Math.ceil(name.length * fontSize * 0.7 * scale) + 4
|
|
346
|
-
const textH = Math.ceil(fontSize * 1.4 * scale) + 4
|
|
347
|
-
tmp.width = textW
|
|
348
|
-
tmp.height = textH
|
|
349
|
-
const ctx = tmp.getContext('2d')
|
|
350
|
-
ctx.scale(scale, scale)
|
|
351
|
-
ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif
|
|
352
|
-
ctx.fillStyle = FRAME_LABEL_COLOR
|
|
353
|
-
ctx.textBaseline = 'top'
|
|
354
|
-
ctx.fillText(name, 0, 0)
|
|
355
|
-
|
|
356
|
-
const imageData = ctx.getImageData(0, 0, textW, textH)
|
|
356
|
+
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
357
|
+
const scale = Math.min(this._zoom * dpr, 4);
|
|
358
|
+
const tmp = document.createElement('canvas');
|
|
359
|
+
const textW = Math.ceil(name.length * fontSize * 0.7 * scale) + 4;
|
|
360
|
+
const textH = Math.ceil(fontSize * 1.4 * scale) + 4;
|
|
361
|
+
tmp.width = textW;
|
|
362
|
+
tmp.height = textH;
|
|
363
|
+
const ctx = tmp.getContext('2d')!;
|
|
364
|
+
ctx.scale(scale, scale);
|
|
365
|
+
ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif`;
|
|
366
|
+
ctx.fillStyle = FRAME_LABEL_COLOR;
|
|
367
|
+
ctx.textBaseline = 'top';
|
|
368
|
+
ctx.fillText(name, 0, 0);
|
|
369
|
+
|
|
370
|
+
const imageData = ctx.getImageData(0, 0, textW, textH);
|
|
357
371
|
const img = ck.MakeImage(
|
|
358
|
-
{
|
|
359
|
-
|
|
360
|
-
|
|
372
|
+
{
|
|
373
|
+
width: textW,
|
|
374
|
+
height: textH,
|
|
375
|
+
alphaType: ck.AlphaType.Unpremul,
|
|
376
|
+
colorType: ck.ColorType.RGBA_8888,
|
|
377
|
+
colorSpace: ck.ColorSpace.SRGB,
|
|
378
|
+
},
|
|
379
|
+
imageData.data,
|
|
380
|
+
textW * 4,
|
|
381
|
+
);
|
|
361
382
|
if (img) {
|
|
362
|
-
const paint = new ck.Paint()
|
|
363
|
-
paint.setAntiAlias(true)
|
|
383
|
+
const paint = new ck.Paint();
|
|
384
|
+
paint.setAntiAlias(true);
|
|
364
385
|
canvas.drawImageRect(
|
|
365
386
|
img,
|
|
366
387
|
ck.LTRBRect(0, 0, textW, textH),
|
|
367
388
|
ck.LTRBRect(x, y - offsetY - fontSize * 1.2, x + textW / scale, y - offsetY),
|
|
368
389
|
paint,
|
|
369
|
-
)
|
|
370
|
-
paint.delete()
|
|
371
|
-
img.delete()
|
|
390
|
+
);
|
|
391
|
+
paint.delete();
|
|
392
|
+
img.delete();
|
|
372
393
|
}
|
|
373
394
|
}
|
|
374
395
|
}
|