@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/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# @zseven-w/pen-renderer
|
|
2
|
+
|
|
3
|
+
Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/nicepkg/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @zseven-w/pen-renderer canvaskit-wasm
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`canvaskit-wasm` is a peer dependency — you provide the WASM binary.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
|
|
17
|
+
|
|
18
|
+
// Initialize CanvasKit
|
|
19
|
+
await loadCanvasKit()
|
|
20
|
+
|
|
21
|
+
// Create renderer on a canvas element
|
|
22
|
+
const renderer = new PenRenderer(canvas, document, {
|
|
23
|
+
width: 1920,
|
|
24
|
+
height: 1080,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// Render
|
|
28
|
+
renderer.render()
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API
|
|
32
|
+
|
|
33
|
+
### High-level
|
|
34
|
+
|
|
35
|
+
- **`loadCanvasKit()`** — Initialize the CanvasKit WASM module
|
|
36
|
+
- **`PenRenderer`** — Full-featured renderer with viewport, selection, and interaction support
|
|
37
|
+
|
|
38
|
+
### Document Flattening
|
|
39
|
+
|
|
40
|
+
Pre-process documents for rendering:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Viewport Utilities
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Low-level Renderers
|
|
53
|
+
|
|
54
|
+
For custom rendering pipelines:
|
|
55
|
+
|
|
56
|
+
- `SkiaNodeRenderer` — Renders individual nodes to a Skia canvas
|
|
57
|
+
- `SkiaTextRenderer` — Text layout and rendering
|
|
58
|
+
- `SkiaFontManager` — Font loading and management
|
|
59
|
+
- `SkiaImageLoader` — Async image loading with caching
|
|
60
|
+
- `SpatialIndex` — R-tree spatial index for hit testing
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zseven-w/pen-renderer",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"import": "./src/index.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"dependencies": {
|
|
19
|
+
"@zseven-w/pen-types": "0.0.1",
|
|
20
|
+
"@zseven-w/pen-core": "0.0.1",
|
|
21
|
+
"rbush": "^4.0.1"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"canvaskit-wasm": "^0.40.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/rbush": "^4.0.0",
|
|
28
|
+
"canvaskit-wasm": "^0.40.0",
|
|
29
|
+
"typescript": "^5.7.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type { PenNode, ContainerProps, RefNode } from '@zseven-w/pen-types'
|
|
2
|
+
import {
|
|
3
|
+
resolvePadding,
|
|
4
|
+
isNodeVisible,
|
|
5
|
+
getNodeWidth,
|
|
6
|
+
getNodeHeight,
|
|
7
|
+
computeLayoutPositions,
|
|
8
|
+
inferLayout,
|
|
9
|
+
parseSizing,
|
|
10
|
+
defaultLineHeight,
|
|
11
|
+
findNodeInTree,
|
|
12
|
+
cssFontFamily,
|
|
13
|
+
} from '@zseven-w/pen-core'
|
|
14
|
+
import { wrapLine } from './paint-utils.js'
|
|
15
|
+
import type { RenderNode } from './types.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Pre-measure text widths using Canvas 2D (browser fonts)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
let _measureCtx: CanvasRenderingContext2D | null = null
|
|
22
|
+
function getMeasureCtx(): CanvasRenderingContext2D {
|
|
23
|
+
if (!_measureCtx) {
|
|
24
|
+
const c = document.createElement('canvas')
|
|
25
|
+
_measureCtx = c.getContext('2d')!
|
|
26
|
+
}
|
|
27
|
+
return _measureCtx
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Walk the node tree and fix text HEIGHTS using actual Canvas 2D wrapping.
|
|
32
|
+
*
|
|
33
|
+
* Only targets fixed-width text with auto height — these are the cases where
|
|
34
|
+
* estimateTextHeight may underestimate because its width estimation differs
|
|
35
|
+
* from Canvas 2D's actual text measurement, leading to incorrect wrap counts.
|
|
36
|
+
*
|
|
37
|
+
* IMPORTANT: This function never touches WIDTH or container-relative sizing
|
|
38
|
+
* strings (fill_container / fit_content). Changing widths breaks layout
|
|
39
|
+
* resolution in computeLayoutPositions.
|
|
40
|
+
*/
|
|
41
|
+
export function premeasureTextHeights(nodes: PenNode[]): PenNode[] {
|
|
42
|
+
return nodes.map((node) => {
|
|
43
|
+
let result = node
|
|
44
|
+
|
|
45
|
+
if (node.type === 'text') {
|
|
46
|
+
const tNode = node as PenNode & { width?: number | string; height?: number | string; fontSize?: number; fontWeight?: string; fontFamily?: string; lineHeight?: number; textAlign?: string; textGrowth?: string; content?: string | { text?: string }[] }
|
|
47
|
+
const hasFixedWidth = typeof tNode.width === 'number' && tNode.width > 0
|
|
48
|
+
const isContainerHeight = typeof tNode.height === 'string'
|
|
49
|
+
&& (tNode.height === 'fill_container' || tNode.height === 'fit_content')
|
|
50
|
+
const textGrowth = tNode.textGrowth
|
|
51
|
+
const content = typeof tNode.content === 'string'
|
|
52
|
+
? tNode.content
|
|
53
|
+
: Array.isArray(tNode.content)
|
|
54
|
+
? tNode.content.map((s) => s.text ?? '').join('')
|
|
55
|
+
: ''
|
|
56
|
+
|
|
57
|
+
const textAlign = tNode.textAlign
|
|
58
|
+
const isFixedWidthText = textGrowth === 'fixed-width' || textGrowth === 'fixed-width-height'
|
|
59
|
+
|| (textGrowth !== 'auto' && textAlign != null && textAlign !== 'left')
|
|
60
|
+
if (content && hasFixedWidth && isFixedWidthText && !isContainerHeight) {
|
|
61
|
+
const fontSize = tNode.fontSize ?? 16
|
|
62
|
+
const fontWeight = tNode.fontWeight ?? '400'
|
|
63
|
+
const fontFamily = tNode.fontFamily ?? 'Inter, -apple-system, "Noto Sans SC", "PingFang SC", system-ui, sans-serif'
|
|
64
|
+
const ctx = getMeasureCtx()
|
|
65
|
+
ctx.font = `${fontWeight} ${fontSize}px ${cssFontFamily(fontFamily)}`
|
|
66
|
+
|
|
67
|
+
const wrapWidth = (tNode.width as number) + fontSize * 0.2
|
|
68
|
+
const rawLines = content.split('\n')
|
|
69
|
+
const wrappedLines: string[] = []
|
|
70
|
+
for (const raw of rawLines) {
|
|
71
|
+
if (!raw) { wrappedLines.push(''); continue }
|
|
72
|
+
wrapLine(ctx, raw, wrapWidth, wrappedLines)
|
|
73
|
+
}
|
|
74
|
+
const lineHeightMul = tNode.lineHeight ?? defaultLineHeight(fontSize)
|
|
75
|
+
const lineHeight = lineHeightMul * fontSize
|
|
76
|
+
const glyphH = fontSize * 1.13
|
|
77
|
+
const measuredHeight = Math.ceil(
|
|
78
|
+
wrappedLines.length <= 1
|
|
79
|
+
? glyphH + 2
|
|
80
|
+
: (wrappedLines.length - 1) * lineHeight + glyphH + 2,
|
|
81
|
+
)
|
|
82
|
+
const currentHeight = typeof tNode.height === 'number' ? tNode.height : 0
|
|
83
|
+
const explicitLineCount = rawLines.length
|
|
84
|
+
const needsHeight = currentHeight <= 0 || wrappedLines.length > explicitLineCount
|
|
85
|
+
if (needsHeight && measuredHeight > currentHeight) {
|
|
86
|
+
result = { ...node, height: measuredHeight } as unknown as PenNode
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Recurse into children
|
|
92
|
+
if ('children' in result && result.children) {
|
|
93
|
+
const children = result.children
|
|
94
|
+
const measured = premeasureTextHeights(children)
|
|
95
|
+
if (measured !== children) {
|
|
96
|
+
result = { ...result, children: measured } as unknown as PenNode
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Flatten document tree -> absolute-positioned RenderNode list
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
interface ClipInfo {
|
|
109
|
+
x: number; y: number; w: number; h: number; rx: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function sizeToNumber(val: number | string | undefined, fallback: number): number {
|
|
113
|
+
if (typeof val === 'number') return val
|
|
114
|
+
if (typeof val === 'string') {
|
|
115
|
+
const m = val.match(/\((\d+(?:\.\d+)?)\)/)
|
|
116
|
+
if (m) return parseFloat(m[1])
|
|
117
|
+
const n = parseFloat(val)
|
|
118
|
+
if (!isNaN(n)) return n
|
|
119
|
+
}
|
|
120
|
+
return fallback
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cornerRadiusVal(cr: number | [number, number, number, number] | undefined): number {
|
|
124
|
+
if (cr === undefined) return 0
|
|
125
|
+
if (typeof cr === 'number') return cr
|
|
126
|
+
return cr[0]
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function flattenToRenderNodes(
|
|
130
|
+
nodes: PenNode[],
|
|
131
|
+
offsetX = 0,
|
|
132
|
+
offsetY = 0,
|
|
133
|
+
parentAvailW?: number,
|
|
134
|
+
parentAvailH?: number,
|
|
135
|
+
clipCtx?: ClipInfo,
|
|
136
|
+
depth = 0,
|
|
137
|
+
): RenderNode[] {
|
|
138
|
+
const result: RenderNode[] = []
|
|
139
|
+
|
|
140
|
+
// Reverse order: children[0] = top layer = rendered last (frontmost)
|
|
141
|
+
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
142
|
+
const node = nodes[i]
|
|
143
|
+
if (!isNodeVisible(node)) continue
|
|
144
|
+
|
|
145
|
+
// Resolve fill_container / fit_content
|
|
146
|
+
let resolved = node
|
|
147
|
+
if (parentAvailW !== undefined || parentAvailH !== undefined) {
|
|
148
|
+
let changed = false
|
|
149
|
+
const r: Record<string, unknown> = { ...node }
|
|
150
|
+
if ('width' in node && typeof node.width !== 'number') {
|
|
151
|
+
const s = parseSizing(node.width)
|
|
152
|
+
if (s === 'fill' && parentAvailW) { r.width = parentAvailW; changed = true }
|
|
153
|
+
else if (s === 'fit') { r.width = getNodeWidth(node, parentAvailW); changed = true }
|
|
154
|
+
}
|
|
155
|
+
if ('height' in node && typeof node.height !== 'number') {
|
|
156
|
+
const s = parseSizing(node.height)
|
|
157
|
+
if (s === 'fill' && parentAvailH) { r.height = parentAvailH; changed = true }
|
|
158
|
+
else if (s === 'fit') { r.height = getNodeHeight(node, parentAvailH, parentAvailW); changed = true }
|
|
159
|
+
}
|
|
160
|
+
if (changed) resolved = r as unknown as PenNode
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Compute height for frames without explicit numeric height
|
|
164
|
+
if (
|
|
165
|
+
node.type === 'frame'
|
|
166
|
+
&& 'children' in node && node.children?.length
|
|
167
|
+
&& (!('height' in resolved) || typeof resolved.height !== 'number')
|
|
168
|
+
) {
|
|
169
|
+
const computedH = getNodeHeight(resolved, parentAvailH, parentAvailW)
|
|
170
|
+
if (computedH > 0) resolved = { ...resolved, height: computedH } as unknown as PenNode
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const absX = (resolved.x ?? 0) + offsetX
|
|
174
|
+
const absY = (resolved.y ?? 0) + offsetY
|
|
175
|
+
const absW = 'width' in resolved ? sizeToNumber(resolved.width, 100) : 100
|
|
176
|
+
const absH = 'height' in resolved ? sizeToNumber(resolved.height, 100) : 100
|
|
177
|
+
|
|
178
|
+
result.push({
|
|
179
|
+
node: { ...resolved, x: absX, y: absY } as PenNode,
|
|
180
|
+
absX, absY, absW, absH,
|
|
181
|
+
clipRect: clipCtx,
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Recurse into children
|
|
185
|
+
const children = 'children' in node ? node.children : undefined
|
|
186
|
+
if (children && children.length > 0) {
|
|
187
|
+
const nodeW = getNodeWidth(resolved, parentAvailW)
|
|
188
|
+
const nodeH = getNodeHeight(resolved, parentAvailH, parentAvailW)
|
|
189
|
+
const pad = resolvePadding('padding' in resolved ? (resolved as PenNode & ContainerProps).padding : undefined)
|
|
190
|
+
const childAvailW = Math.max(0, nodeW - pad.left - pad.right)
|
|
191
|
+
const childAvailH = Math.max(0, nodeH - pad.top - pad.bottom)
|
|
192
|
+
|
|
193
|
+
const layout = ('layout' in node ? (node as ContainerProps).layout : undefined) || inferLayout(node)
|
|
194
|
+
const positioned = layout && layout !== 'none'
|
|
195
|
+
? computeLayoutPositions(resolved, children)
|
|
196
|
+
: children
|
|
197
|
+
|
|
198
|
+
// Clipping — only clip for root frames (artboard behavior).
|
|
199
|
+
let childClip = clipCtx
|
|
200
|
+
const isRootFrame = node.type === 'frame' && depth === 0
|
|
201
|
+
if (isRootFrame) {
|
|
202
|
+
const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0
|
|
203
|
+
const cr = Math.min(crRaw, nodeH / 2)
|
|
204
|
+
childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const childRNs = flattenToRenderNodes(positioned, absX, absY, childAvailW, childAvailH, childClip, depth + 1)
|
|
208
|
+
|
|
209
|
+
// Propagate parent flip to children
|
|
210
|
+
const parentFlipX = node.flipX === true
|
|
211
|
+
const parentFlipY = node.flipY === true
|
|
212
|
+
if (parentFlipX || parentFlipY) {
|
|
213
|
+
const pcx = absX + nodeW / 2
|
|
214
|
+
const pcy = absY + nodeH / 2
|
|
215
|
+
for (const crn of childRNs) {
|
|
216
|
+
const updates: Record<string, unknown> = {}
|
|
217
|
+
if (parentFlipX) {
|
|
218
|
+
const ccx = crn.absX + crn.absW / 2
|
|
219
|
+
crn.absX = 2 * pcx - ccx - crn.absW / 2
|
|
220
|
+
const childFlip = crn.node.flipX === true
|
|
221
|
+
updates.flipX = !childFlip || undefined
|
|
222
|
+
}
|
|
223
|
+
if (parentFlipY) {
|
|
224
|
+
const ccy = crn.absY + crn.absH / 2
|
|
225
|
+
crn.absY = 2 * pcy - ccy - crn.absH / 2
|
|
226
|
+
const childFlip = crn.node.flipY === true
|
|
227
|
+
updates.flipY = !childFlip || undefined
|
|
228
|
+
}
|
|
229
|
+
crn.node = { ...crn.node, x: crn.absX, y: crn.absY, ...updates } as PenNode
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Propagate parent rotation to children
|
|
234
|
+
const parentRot = node.rotation ?? 0
|
|
235
|
+
if (parentRot !== 0) {
|
|
236
|
+
const cx = absX + nodeW / 2
|
|
237
|
+
const cy = absY + nodeH / 2
|
|
238
|
+
const rad = parentRot * Math.PI / 180
|
|
239
|
+
const cosA = Math.cos(rad)
|
|
240
|
+
const sinA = Math.sin(rad)
|
|
241
|
+
|
|
242
|
+
for (const crn of childRNs) {
|
|
243
|
+
const ccx = crn.absX + crn.absW / 2
|
|
244
|
+
const ccy = crn.absY + crn.absH / 2
|
|
245
|
+
const dx = ccx - cx
|
|
246
|
+
const dy = ccy - cy
|
|
247
|
+
const newCx = cx + dx * cosA - dy * sinA
|
|
248
|
+
const newCy = cy + dx * sinA + dy * cosA
|
|
249
|
+
crn.absX = newCx - crn.absW / 2
|
|
250
|
+
crn.absY = newCy - crn.absH / 2
|
|
251
|
+
const childRot = crn.node.rotation ?? 0
|
|
252
|
+
crn.node = { ...crn.node, x: crn.absX, y: crn.absY, rotation: childRot + parentRot } as PenNode
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
result.push(...childRNs)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return result
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// Ref resolution — resolve RefNodes to their target components
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
/** Resolve RefNodes inline (same logic as use-canvas-sync.ts). */
|
|
268
|
+
export function resolveRefs(
|
|
269
|
+
nodes: PenNode[],
|
|
270
|
+
rootNodes: PenNode[],
|
|
271
|
+
findInTree?: (nodes: PenNode[], id: string) => PenNode | null,
|
|
272
|
+
visited = new Set<string>(),
|
|
273
|
+
): PenNode[] {
|
|
274
|
+
const finder = findInTree ?? ((ns: PenNode[], id: string) => findNodeInTree(ns, id) ?? null)
|
|
275
|
+
return nodes.flatMap((node) => {
|
|
276
|
+
if (node.type !== 'ref') {
|
|
277
|
+
if ('children' in node && node.children) {
|
|
278
|
+
return [{ ...node, children: resolveRefs(node.children, rootNodes, finder, visited) } as PenNode]
|
|
279
|
+
}
|
|
280
|
+
return [node]
|
|
281
|
+
}
|
|
282
|
+
if (visited.has(node.ref)) return []
|
|
283
|
+
const component = finder(rootNodes, node.ref)
|
|
284
|
+
if (!component) return []
|
|
285
|
+
visited.add(node.ref)
|
|
286
|
+
const resolved: Record<string, unknown> = { ...component }
|
|
287
|
+
for (const [key, val] of Object.entries(node)) {
|
|
288
|
+
if (key === 'type' || key === 'ref' || key === 'descendants' || key === 'children') continue
|
|
289
|
+
if (val !== undefined) resolved[key] = val
|
|
290
|
+
}
|
|
291
|
+
resolved.type = component.type
|
|
292
|
+
if (!resolved.name) resolved.name = component.name
|
|
293
|
+
delete resolved.reusable
|
|
294
|
+
const resolvedNode = resolved as unknown as PenNode
|
|
295
|
+
if ('children' in component && component.children) {
|
|
296
|
+
const refNode = node as RefNode
|
|
297
|
+
;(resolvedNode as PenNode & ContainerProps).children = remapIds(component.children, node.id, refNode.descendants)
|
|
298
|
+
}
|
|
299
|
+
visited.delete(node.ref)
|
|
300
|
+
return [resolvedNode]
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function remapIds(children: PenNode[], refId: string, overrides?: Record<string, Partial<PenNode>>): PenNode[] {
|
|
305
|
+
return children.map((child) => {
|
|
306
|
+
const virtualId = `${refId}__${child.id}`
|
|
307
|
+
const ov = overrides?.[child.id] ?? {}
|
|
308
|
+
const mapped = { ...child, ...ov, id: virtualId } as PenNode
|
|
309
|
+
if ('children' in mapped && mapped.children) {
|
|
310
|
+
(mapped as PenNode & ContainerProps).children = remapIds(mapped.children, refId, overrides)
|
|
311
|
+
}
|
|
312
|
+
return mapped
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// Component / instance ID collection (from raw tree, before ref resolution)
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
export function collectReusableIds(nodes: PenNode[], result: Set<string>) {
|
|
321
|
+
for (const node of nodes) {
|
|
322
|
+
if (node.type === 'frame' && node.reusable === true) {
|
|
323
|
+
result.add(node.id)
|
|
324
|
+
}
|
|
325
|
+
if ('children' in node && node.children) {
|
|
326
|
+
collectReusableIds(node.children, result)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function collectInstanceIds(nodes: PenNode[], result: Set<string>) {
|
|
332
|
+
for (const node of nodes) {
|
|
333
|
+
if (node.type === 'ref') {
|
|
334
|
+
result.add(node.id)
|
|
335
|
+
}
|
|
336
|
+
if ('children' in node && node.children) {
|
|
337
|
+
collectInstanceIds(node.children, result)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|