@zseven-w/pen-renderer 0.0.1

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.
@@ -0,0 +1,374 @@
1
+ import type { CanvasKit, Surface } from 'canvaskit-wasm'
2
+ import type { PenDocument, PenNode } from '@zseven-w/pen-types'
3
+ import {
4
+ getActivePageChildren,
5
+ getAllChildren,
6
+ getDefaultTheme,
7
+ resolveNodeForCanvas,
8
+ MIN_ZOOM,
9
+ MAX_ZOOM,
10
+ CANVAS_BACKGROUND_DARK,
11
+ FRAME_LABEL_FONT_SIZE,
12
+ FRAME_LABEL_OFFSET_Y,
13
+ FRAME_LABEL_COLOR,
14
+ setRootChildrenProvider,
15
+ } from '@zseven-w/pen-core'
16
+ import { SkiaNodeRenderer } from './node-renderer.js'
17
+ import { SpatialIndex } from './spatial-index.js'
18
+ import {
19
+ flattenToRenderNodes,
20
+ resolveRefs,
21
+ premeasureTextHeights,
22
+ collectReusableIds,
23
+ collectInstanceIds,
24
+ } from './document-flattener.js'
25
+ import { parseColor } from './paint-utils.js'
26
+ import {
27
+ viewportMatrix,
28
+ screenToScene,
29
+ zoomToPoint as vpZoomToPoint,
30
+ } from './viewport.js'
31
+ import type { RenderNode, PenRendererOptions, ViewportState } from './types.js'
32
+
33
+ /**
34
+ * Standalone read-only renderer for OpenPencil (.op) design files.
35
+ * No React, no Zustand, no TanStack — just pure TypeScript + CanvasKit.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
40
+ *
41
+ * const ck = await loadCanvasKit('/canvaskit/')
42
+ * const renderer = new PenRenderer(ck, { fontBasePath: '/fonts/' })
43
+ * renderer.init(document.getElementById('canvas') as HTMLCanvasElement)
44
+ * renderer.setDocument(myDocument)
45
+ * renderer.zoomToFit()
46
+ * ```
47
+ */
48
+ 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
56
+
57
+ // Component/instance IDs for colored frame labels
58
+ private reusableIds = new Set<string>()
59
+ private instanceIds = new Set<string>()
60
+
61
+ // Viewport
62
+ private _zoom = 1
63
+ private _panX = 0
64
+ private _panY = 0
65
+ private dirty = true
66
+ private animFrameId = 0
67
+
68
+ // Document
69
+ private document: PenDocument | null = null
70
+ private activePageId: string | null = null
71
+
72
+ constructor(ck: CanvasKit, options?: PenRendererOptions) {
73
+ this.ck = ck
74
+ this.options = options ?? {}
75
+ this.nodeRenderer = new SkiaNodeRenderer(ck, {
76
+ fontBasePath: this.options.fontBasePath,
77
+ googleFontsCssUrl: this.options.googleFontsCssUrl,
78
+ })
79
+ if (this.options.iconLookup) {
80
+ this.nodeRenderer.setIconLookup(this.options.iconLookup)
81
+ }
82
+ if (this.options.devicePixelRatio !== undefined) {
83
+ this.nodeRenderer.devicePixelRatio = this.options.devicePixelRatio
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Lifecycle
89
+ // ---------------------------------------------------------------------------
90
+
91
+ 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) { console.error('PenRenderer: Failed to create surface'); return }
100
+
101
+ this.nodeRenderer.init()
102
+ this.nodeRenderer.setRedrawCallback(() => this.markDirty())
103
+ ;(this.nodeRenderer as any).textRenderer._onFontLoaded = () => this.markDirty()
104
+
105
+ // Pre-load default fonts
106
+ const defaultFonts = this.options.defaultFonts ?? ['Inter', 'Noto Sans SC']
107
+ for (const font of defaultFonts) {
108
+ this.nodeRenderer.fontManager.ensureFont(font).then(() => this.markDirty())
109
+ }
110
+
111
+ // Wire up root children provider for layout engine fill-width fallback
112
+ setRootChildrenProvider(() => this.document?.children ?? [])
113
+
114
+ this.startRenderLoop()
115
+ }
116
+
117
+ dispose() {
118
+ if (this.animFrameId) cancelAnimationFrame(this.animFrameId)
119
+ this.nodeRenderer.dispose()
120
+ this.surface?.delete()
121
+ this.surface = null
122
+ }
123
+
124
+ 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()
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Document
137
+ // ---------------------------------------------------------------------------
138
+
139
+ setDocument(doc: PenDocument) {
140
+ this.document = doc
141
+ this.activePageId = doc.pages?.[0]?.id ?? null
142
+ this.syncFromDocument()
143
+ }
144
+
145
+ getDocument(): PenDocument | null {
146
+ return this.document
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Pages
151
+ // ---------------------------------------------------------------------------
152
+
153
+ setPage(pageId: string) {
154
+ this.activePageId = pageId
155
+ this.syncFromDocument()
156
+ }
157
+
158
+ getPageIds(): string[] {
159
+ return this.document?.pages?.map(p => p.id) ?? []
160
+ }
161
+
162
+ getActivePageId(): string | null {
163
+ return this.activePageId
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Viewport
168
+ // ---------------------------------------------------------------------------
169
+
170
+ 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()
175
+ }
176
+
177
+ getViewport(): ViewportState {
178
+ return { zoom: this._zoom, panX: this._panX, panY: this._panY }
179
+ }
180
+
181
+ zoomToFit(padding = 64) {
182
+ if (!this.canvasEl || this.renderNodes.length === 0) return
183
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
184
+ 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)
190
+ }
191
+ if (!isFinite(minX)) return
192
+
193
+ const contentW = maxX - minX
194
+ const contentH = maxY - minY
195
+ const canvasW = this.canvasEl.clientWidth
196
+ const canvasH = this.canvasEl.clientHeight
197
+ const zoom = Math.min(
198
+ (canvasW - padding * 2) / contentW,
199
+ (canvasH - padding * 2) / contentH,
200
+ 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)
205
+ }
206
+
207
+ zoomToPoint(screenX: number, screenY: number, newZoom: number) {
208
+ if (!this.canvasEl) return
209
+ const rect = this.canvasEl.getBoundingClientRect()
210
+ const vp = vpZoomToPoint(
211
+ { zoom: this._zoom, panX: this._panX, panY: this._panY },
212
+ screenX, screenY, rect, newZoom,
213
+ )
214
+ this.setViewport(vp.zoom, vp.panX, vp.panY)
215
+ }
216
+
217
+ pan(dx: number, dy: number) {
218
+ this.setViewport(this._zoom, this._panX + dx, this._panY + dy)
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Theme
223
+ // ---------------------------------------------------------------------------
224
+
225
+ setThemeVariant(variant: Record<string, string>) {
226
+ this.options.themeVariant = variant
227
+ this.syncFromDocument()
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // Hit testing
232
+ // ---------------------------------------------------------------------------
233
+
234
+ 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, { zoom: this._zoom, panX: this._panX, panY: this._panY })
238
+ const hits = this.spatialIndex.hitTest(scene.x, scene.y)
239
+ return hits.length > 0 ? hits[0].node : null
240
+ }
241
+
242
+ 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 }
246
+ }
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // Internal: Document sync
250
+ // ---------------------------------------------------------------------------
251
+
252
+ private syncFromDocument() {
253
+ if (!this.document) return
254
+ const pageChildren = getActivePageChildren(this.document, this.activePageId)
255
+ const allNodes = getAllChildren(this.document)
256
+
257
+ // Collect reusable/instance IDs
258
+ this.reusableIds.clear()
259
+ this.instanceIds.clear()
260
+ collectReusableIds(pageChildren, this.reusableIds)
261
+ collectInstanceIds(pageChildren, this.instanceIds)
262
+
263
+ // Resolve refs
264
+ const resolved = resolveRefs(pageChildren, allNodes)
265
+
266
+ // 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))
271
+
272
+ // Pre-measure text heights
273
+ const measured = premeasureTextHeights(variableResolved)
274
+
275
+ this.renderNodes = flattenToRenderNodes(measured)
276
+ this.spatialIndex.rebuild(this.renderNodes)
277
+ this.markDirty()
278
+ }
279
+
280
+ // ---------------------------------------------------------------------------
281
+ // Render loop
282
+ // ---------------------------------------------------------------------------
283
+
284
+ private markDirty() {
285
+ this.dirty = true
286
+ }
287
+
288
+ private startRenderLoop() {
289
+ 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)
296
+ }
297
+
298
+ 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
303
+
304
+ // Clear
305
+ const bgColor = this.options.backgroundColor ?? CANVAS_BACKGROUND_DARK
306
+ canvas.clear(parseColor(ck, bgColor))
307
+
308
+ // Apply viewport transform
309
+ canvas.save()
310
+ canvas.scale(dpr, dpr)
311
+ canvas.concat(viewportMatrix({ zoom: this._zoom, panX: this._panX, panY: this._panY }))
312
+
313
+ // Pass current zoom to renderer
314
+ this.nodeRenderer.zoom = this._zoom
315
+
316
+ // Draw all render nodes
317
+ for (const rn of this.renderNodes) {
318
+ this.nodeRenderer.drawNode(canvas, rn)
319
+ }
320
+
321
+ // Draw frame labels for root frames + reusable + instances
322
+ 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)
329
+ }
330
+
331
+ canvas.restore()
332
+ this.surface.flush()
333
+ }
334
+
335
+ /** Simple frame label drawing for read-only renderer. */
336
+ private drawFrameLabel(canvas: ReturnType<Surface['getCanvas']>, name: string, x: number, y: number) {
337
+ const ck = this.ck
338
+ const fontSize = FRAME_LABEL_FONT_SIZE / this._zoom
339
+ const offsetY = FRAME_LABEL_OFFSET_Y / this._zoom
340
+
341
+ // 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)
357
+ const img = ck.MakeImage(
358
+ { width: textW, height: textH, alphaType: ck.AlphaType.Unpremul, colorType: ck.ColorType.RGBA_8888, colorSpace: ck.ColorSpace.SRGB },
359
+ imageData.data, textW * 4,
360
+ )
361
+ if (img) {
362
+ const paint = new ck.Paint()
363
+ paint.setAntiAlias(true)
364
+ canvas.drawImageRect(
365
+ img,
366
+ ck.LTRBRect(0, 0, textW, textH),
367
+ ck.LTRBRect(x, y - offsetY - fontSize * 1.2, x + textW / scale, y - offsetY),
368
+ paint,
369
+ )
370
+ paint.delete()
371
+ img.delete()
372
+ }
373
+ }
374
+ }
@@ -0,0 +1,89 @@
1
+ import RBush from 'rbush'
2
+ import type { RenderNode } from './types.js'
3
+
4
+ interface RTreeItem {
5
+ minX: number
6
+ minY: number
7
+ maxX: number
8
+ maxY: number
9
+ nodeId: string
10
+ renderNode: RenderNode
11
+ /** Position in the render array — higher = rendered later = visually on top */
12
+ zIndex: number
13
+ }
14
+
15
+ /**
16
+ * Spatial index for fast hit testing using R-tree.
17
+ * Nodes are indexed with their render order so hit results
18
+ * are sorted topmost-first (children before parents).
19
+ */
20
+ export class SpatialIndex {
21
+ private tree = new RBush<RTreeItem>()
22
+ private items = new Map<string, RTreeItem>()
23
+
24
+ /**
25
+ * Rebuild the entire index from a list of render nodes.
26
+ */
27
+ rebuild(nodes: RenderNode[]) {
28
+ this.tree.clear()
29
+ this.items.clear()
30
+
31
+ const items: RTreeItem[] = []
32
+ for (let i = 0; i < nodes.length; i++) {
33
+ const rn = nodes[i]
34
+ if (('visible' in rn.node ? rn.node.visible : undefined) === false) continue
35
+ if (('locked' in rn.node ? rn.node.locked : undefined) === true) continue
36
+
37
+ const item: RTreeItem = {
38
+ minX: rn.absX,
39
+ minY: rn.absY,
40
+ maxX: rn.absX + rn.absW,
41
+ maxY: rn.absY + rn.absH,
42
+ nodeId: rn.node.id,
43
+ renderNode: rn,
44
+ zIndex: i,
45
+ }
46
+ items.push(item)
47
+ this.items.set(rn.node.id, item)
48
+ }
49
+
50
+ this.tree.load(items)
51
+ }
52
+
53
+ /**
54
+ * Find all nodes that contain the given scene point.
55
+ * Returns nodes sorted by z-order: topmost (highest zIndex) first.
56
+ */
57
+ hitTest(sceneX: number, sceneY: number): RenderNode[] {
58
+ const candidates = this.tree.search({
59
+ minX: sceneX,
60
+ minY: sceneY,
61
+ maxX: sceneX,
62
+ maxY: sceneY,
63
+ })
64
+
65
+ // Sort by zIndex descending — children (rendered later) come first
66
+ candidates.sort((a, b) => b.zIndex - a.zIndex)
67
+ return candidates.map((c) => c.renderNode)
68
+ }
69
+
70
+ /**
71
+ * Find all nodes that intersect with a rectangle (for marquee selection).
72
+ */
73
+ searchRect(left: number, top: number, right: number, bottom: number): RenderNode[] {
74
+ const candidates = this.tree.search({
75
+ minX: Math.min(left, right),
76
+ minY: Math.min(top, bottom),
77
+ maxX: Math.max(left, right),
78
+ maxY: Math.max(top, bottom),
79
+ })
80
+ return candidates.map((c) => c.renderNode)
81
+ }
82
+
83
+ /**
84
+ * Get the render node for a specific node ID.
85
+ */
86
+ get(nodeId: string): RenderNode | undefined {
87
+ return this.items.get(nodeId)?.renderNode
88
+ }
89
+ }