@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.
- package/README.md +64 -0
- package/package.json +31 -0
- package/src/document-flattener.ts +340 -0
- package/src/font-manager.ts +401 -0
- package/src/image-loader.ts +93 -0
- package/src/index.ts +60 -0
- package/src/init.ts +44 -0
- package/src/node-renderer.ts +599 -0
- package/src/paint-utils.ts +148 -0
- package/src/path-utils.ts +225 -0
- package/src/renderer.ts +374 -0
- package/src/spatial-index.ts +89 -0
- package/src/text-renderer.ts +531 -0
- package/src/types.ts +40 -0
- package/src/viewport.ts +102 -0
package/src/renderer.ts
ADDED
|
@@ -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
|
+
}
|