@zseven-w/pen-core 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 +80 -0
- package/package.json +26 -0
- package/src/__tests__/arc-path.test.ts +39 -0
- package/src/__tests__/font-utils.test.ts +26 -0
- package/src/__tests__/layout-engine.test.ts +153 -0
- package/src/__tests__/node-helpers.test.ts +30 -0
- package/src/__tests__/normalize.test.ts +110 -0
- package/src/__tests__/text-measure.test.ts +147 -0
- package/src/__tests__/tree-utils.test.ts +170 -0
- package/src/__tests__/variables.test.ts +132 -0
- package/src/arc-path.ts +100 -0
- package/src/boolean-ops.ts +256 -0
- package/src/constants.ts +49 -0
- package/src/font-utils.ts +23 -0
- package/src/id.ts +1 -0
- package/src/index.ts +133 -0
- package/src/layout/engine.ts +460 -0
- package/src/layout/text-measure.ts +269 -0
- package/src/node-helpers.ts +14 -0
- package/src/normalize.ts +283 -0
- package/src/sync-lock.ts +16 -0
- package/src/tree-utils.ts +390 -0
- package/src/variables/replace-refs.ts +149 -0
- package/src/variables/resolve.ts +284 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { nanoid } from 'nanoid'
|
|
2
|
+
import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types'
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_FRAME_ID = 'root-frame'
|
|
5
|
+
export const DEFAULT_PAGE_ID = 'page-1'
|
|
6
|
+
|
|
7
|
+
export function createEmptyDocument(): PenDocument {
|
|
8
|
+
const children: PenNode[] = [
|
|
9
|
+
{
|
|
10
|
+
id: DEFAULT_FRAME_ID,
|
|
11
|
+
type: 'frame',
|
|
12
|
+
name: 'Frame',
|
|
13
|
+
x: 0,
|
|
14
|
+
y: 0,
|
|
15
|
+
width: 1200,
|
|
16
|
+
height: 800,
|
|
17
|
+
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
|
18
|
+
children: [],
|
|
19
|
+
},
|
|
20
|
+
]
|
|
21
|
+
return {
|
|
22
|
+
version: '1.0.0',
|
|
23
|
+
pages: [{ id: DEFAULT_PAGE_ID, name: 'Page 1', children }],
|
|
24
|
+
children: [],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Page helpers — centralize page-aware children access
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/** Get the active page object. */
|
|
33
|
+
export function getActivePage(
|
|
34
|
+
doc: PenDocument,
|
|
35
|
+
activePageId: string | null,
|
|
36
|
+
): PenPage | undefined {
|
|
37
|
+
if (!doc.pages || doc.pages.length === 0) return undefined
|
|
38
|
+
if (!activePageId) return doc.pages[0]
|
|
39
|
+
return doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get children for the active page (falls back to doc.children for legacy docs). */
|
|
43
|
+
export function getActivePageChildren(
|
|
44
|
+
doc: PenDocument,
|
|
45
|
+
activePageId: string | null,
|
|
46
|
+
): PenNode[] {
|
|
47
|
+
const page = getActivePage(doc, activePageId)
|
|
48
|
+
if (page) return page.children
|
|
49
|
+
return doc.children
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Return a new document with the active page's children replaced. */
|
|
53
|
+
export function setActivePageChildren(
|
|
54
|
+
doc: PenDocument,
|
|
55
|
+
activePageId: string | null,
|
|
56
|
+
children: PenNode[],
|
|
57
|
+
): PenDocument {
|
|
58
|
+
if (doc.pages && doc.pages.length > 0) {
|
|
59
|
+
const page = getActivePage(doc, activePageId)
|
|
60
|
+
if (!page) return { ...doc, children }
|
|
61
|
+
return {
|
|
62
|
+
...doc,
|
|
63
|
+
pages: doc.pages.map((p) =>
|
|
64
|
+
p.id === page.id ? { ...p, children } : p,
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { ...doc, children }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Get all children across all pages (for cross-page component resolution). */
|
|
72
|
+
export function getAllChildren(doc: PenDocument): PenNode[] {
|
|
73
|
+
if (doc.pages && doc.pages.length > 0) {
|
|
74
|
+
return doc.pages.flatMap((p) => p.children)
|
|
75
|
+
}
|
|
76
|
+
return doc.children
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Migrate a legacy document (no pages) to page-based format. */
|
|
80
|
+
export function migrateToPages(doc: PenDocument): PenDocument {
|
|
81
|
+
if (doc.pages && doc.pages.length > 0) return doc
|
|
82
|
+
return {
|
|
83
|
+
...doc,
|
|
84
|
+
pages: [
|
|
85
|
+
{
|
|
86
|
+
id: DEFAULT_PAGE_ID,
|
|
87
|
+
name: 'Page 1',
|
|
88
|
+
children: doc.children,
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
children: [],
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Recursively ensure all nodes in the tree have an `id`. */
|
|
96
|
+
function ensureNodeIdsInTree(nodes: PenNode[]): void {
|
|
97
|
+
for (const node of nodes) {
|
|
98
|
+
if (!node.id) {
|
|
99
|
+
;(node as PenNodeBase).id = nanoid()
|
|
100
|
+
}
|
|
101
|
+
if ('children' in node && node.children) {
|
|
102
|
+
ensureNodeIdsInTree(node.children)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Ensure all nodes in a document have IDs (mutates in place). */
|
|
108
|
+
export function ensureDocumentNodeIds(doc: PenDocument): PenDocument {
|
|
109
|
+
if (doc.pages) {
|
|
110
|
+
for (const page of doc.pages) {
|
|
111
|
+
if (!page.id) page.id = nanoid()
|
|
112
|
+
ensureNodeIdsInTree(page.children)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
ensureNodeIdsInTree(doc.children)
|
|
116
|
+
return doc
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function findNodeInTree(
|
|
120
|
+
nodes: PenNode[],
|
|
121
|
+
id: string,
|
|
122
|
+
): PenNode | undefined {
|
|
123
|
+
for (const node of nodes) {
|
|
124
|
+
if (node.id === id) return node
|
|
125
|
+
if ('children' in node && node.children) {
|
|
126
|
+
const found = findNodeInTree(node.children, id)
|
|
127
|
+
if (found) return found
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function findParentInTree(
|
|
134
|
+
nodes: PenNode[],
|
|
135
|
+
id: string,
|
|
136
|
+
): PenNode | undefined {
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if ('children' in node && node.children) {
|
|
139
|
+
for (const child of node.children) {
|
|
140
|
+
if (child.id === id) return node
|
|
141
|
+
}
|
|
142
|
+
const found = findParentInTree(node.children, id)
|
|
143
|
+
if (found) return found
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
|
|
150
|
+
return nodes
|
|
151
|
+
.filter((n) => n.id !== id)
|
|
152
|
+
.map((n) => {
|
|
153
|
+
if ('children' in n && n.children) {
|
|
154
|
+
return { ...n, children: removeNodeFromTree(n.children, id) }
|
|
155
|
+
}
|
|
156
|
+
return n
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function updateNodeInTree(
|
|
161
|
+
nodes: PenNode[],
|
|
162
|
+
id: string,
|
|
163
|
+
updates: Partial<PenNode>,
|
|
164
|
+
): PenNode[] {
|
|
165
|
+
return nodes.map((n) => {
|
|
166
|
+
if (n.id === id) {
|
|
167
|
+
return { ...n, ...updates } as PenNode
|
|
168
|
+
}
|
|
169
|
+
if ('children' in n && n.children) {
|
|
170
|
+
return {
|
|
171
|
+
...n,
|
|
172
|
+
children: updateNodeInTree(n.children, id, updates),
|
|
173
|
+
} as PenNode
|
|
174
|
+
}
|
|
175
|
+
return n
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function flattenNodes(nodes: PenNode[]): PenNode[] {
|
|
180
|
+
const result: PenNode[] = []
|
|
181
|
+
for (const node of nodes) {
|
|
182
|
+
result.push(node)
|
|
183
|
+
if ('children' in node && node.children) {
|
|
184
|
+
result.push(...flattenNodes(node.children))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function insertNodeInTree(
|
|
191
|
+
nodes: PenNode[],
|
|
192
|
+
parentId: string | null,
|
|
193
|
+
node: PenNode,
|
|
194
|
+
index?: number,
|
|
195
|
+
): PenNode[] {
|
|
196
|
+
if (parentId === null) {
|
|
197
|
+
const arr = [...nodes]
|
|
198
|
+
if (index !== undefined) {
|
|
199
|
+
arr.splice(index, 0, node)
|
|
200
|
+
} else {
|
|
201
|
+
arr.push(node)
|
|
202
|
+
}
|
|
203
|
+
return arr
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return nodes.map((n) => {
|
|
207
|
+
if (n.id === parentId) {
|
|
208
|
+
const children = 'children' in n && n.children ? [...n.children] : []
|
|
209
|
+
if (index !== undefined) {
|
|
210
|
+
children.splice(index, 0, node)
|
|
211
|
+
} else {
|
|
212
|
+
children.push(node)
|
|
213
|
+
}
|
|
214
|
+
return { ...n, children } as PenNode
|
|
215
|
+
}
|
|
216
|
+
if ('children' in n && n.children) {
|
|
217
|
+
return {
|
|
218
|
+
...n,
|
|
219
|
+
children: insertNodeInTree(n.children, parentId, node, index),
|
|
220
|
+
} as PenNode
|
|
221
|
+
}
|
|
222
|
+
return n
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isDescendantOf(
|
|
227
|
+
nodes: PenNode[],
|
|
228
|
+
nodeId: string,
|
|
229
|
+
ancestorId: string,
|
|
230
|
+
): boolean {
|
|
231
|
+
const ancestor = findNodeInTree(nodes, ancestorId)
|
|
232
|
+
if (!ancestor || !('children' in ancestor) || !ancestor.children) return false
|
|
233
|
+
for (const child of ancestor.children) {
|
|
234
|
+
if (child.id === nodeId) return true
|
|
235
|
+
if (isDescendantOf([child], nodeId, child.id)) return true
|
|
236
|
+
}
|
|
237
|
+
return false
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */
|
|
241
|
+
export function getNodeBounds(
|
|
242
|
+
node: PenNode,
|
|
243
|
+
allNodes: PenNode[],
|
|
244
|
+
): { x: number; y: number; w: number; h: number } {
|
|
245
|
+
const x = node.x ?? 0
|
|
246
|
+
const y = node.y ?? 0
|
|
247
|
+
let w = ('width' in node && typeof node.width === 'number') ? node.width : 0
|
|
248
|
+
let h = ('height' in node && typeof node.height === 'number') ? node.height : 0
|
|
249
|
+
if (node.type === 'ref' && !w) {
|
|
250
|
+
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
|
|
251
|
+
if (refComp) {
|
|
252
|
+
w = ('width' in refComp && typeof refComp.width === 'number') ? refComp.width : 100
|
|
253
|
+
h = ('height' in refComp && typeof refComp.height === 'number') ? refComp.height : 100
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { x, y, w: w || 100, h: h || 100 }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Find a clear X position to the right of `sourceX + sourceW` that doesn't
|
|
261
|
+
* overlap any sibling (excluding `excludeId`) on the same vertical band.
|
|
262
|
+
*/
|
|
263
|
+
export function findClearX(
|
|
264
|
+
sourceX: number,
|
|
265
|
+
sourceW: number,
|
|
266
|
+
proposedY: number,
|
|
267
|
+
proposedH: number,
|
|
268
|
+
siblings: PenNode[],
|
|
269
|
+
excludeId: string,
|
|
270
|
+
allNodes: PenNode[],
|
|
271
|
+
gap = 20,
|
|
272
|
+
): number {
|
|
273
|
+
const proposedW = sourceW
|
|
274
|
+
let proposedX = sourceX + sourceW + gap
|
|
275
|
+
|
|
276
|
+
const siblingBounds: { x: number; y: number; w: number; h: number }[] = []
|
|
277
|
+
for (const sib of siblings) {
|
|
278
|
+
if (sib.id === excludeId) continue
|
|
279
|
+
const b = getNodeBounds(sib, allNodes)
|
|
280
|
+
if (b.w > 0 && b.h > 0) siblingBounds.push(b)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let maxAttempts = 100
|
|
284
|
+
while (maxAttempts-- > 0) {
|
|
285
|
+
const hasOverlap = siblingBounds.some((b) => {
|
|
286
|
+
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
|
|
287
|
+
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
|
|
288
|
+
return overlapX && overlapY
|
|
289
|
+
})
|
|
290
|
+
if (!hasOverlap) break
|
|
291
|
+
let maxRight = proposedX
|
|
292
|
+
for (const b of siblingBounds) {
|
|
293
|
+
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x
|
|
294
|
+
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y
|
|
295
|
+
if (overlapX && overlapY && b.x + b.w > maxRight) {
|
|
296
|
+
maxRight = b.x + b.w
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
proposedX = maxRight + gap
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return proposedX
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Recursively scale all children's relative positions and sizes. */
|
|
306
|
+
export function scaleChildrenInPlace(
|
|
307
|
+
children: PenNode[],
|
|
308
|
+
scaleX: number,
|
|
309
|
+
scaleY: number,
|
|
310
|
+
): PenNode[] {
|
|
311
|
+
return children.map((child) => {
|
|
312
|
+
const updated: Record<string, unknown> = { ...child }
|
|
313
|
+
if (child.x !== undefined) updated.x = child.x * scaleX
|
|
314
|
+
if (child.y !== undefined) updated.y = child.y * scaleY
|
|
315
|
+
if ('width' in child && typeof child.width === 'number') {
|
|
316
|
+
updated.width = child.width * scaleX
|
|
317
|
+
}
|
|
318
|
+
if ('height' in child && typeof child.height === 'number') {
|
|
319
|
+
updated.height = child.height * scaleY
|
|
320
|
+
}
|
|
321
|
+
if ('children' in child && child.children) {
|
|
322
|
+
updated.children = scaleChildrenInPlace(child.children, scaleX, scaleY)
|
|
323
|
+
}
|
|
324
|
+
return updated as unknown as PenNode
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Clone utilities
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/** Deep-clone a node tree preserving all IDs. */
|
|
333
|
+
export function deepCloneNode<T extends PenNode>(node: T): T {
|
|
334
|
+
return structuredClone(node)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Clone a single node tree, assigning new IDs to every node. */
|
|
338
|
+
export function cloneNodeWithNewIds(
|
|
339
|
+
node: PenNode,
|
|
340
|
+
idGenerator: () => string = nanoid,
|
|
341
|
+
): PenNode {
|
|
342
|
+
const cloned = { ...node, id: idGenerator() } as PenNode
|
|
343
|
+
if ('children' in cloned && cloned.children) {
|
|
344
|
+
cloned.children = cloned.children.map((c) =>
|
|
345
|
+
cloneNodeWithNewIds(c, idGenerator),
|
|
346
|
+
)
|
|
347
|
+
}
|
|
348
|
+
return cloned
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Clone multiple nodes with new IDs. Optionally strip `reusable` flag and apply position offset. */
|
|
352
|
+
export function cloneNodesWithNewIds(
|
|
353
|
+
nodes: PenNode[],
|
|
354
|
+
options: { offset?: number; stripReusable?: boolean; idGenerator?: () => string } = {},
|
|
355
|
+
): PenNode[] {
|
|
356
|
+
const { offset = 0, stripReusable = true, idGenerator = nanoid } = options
|
|
357
|
+
return structuredClone(nodes).map((node) => {
|
|
358
|
+
const withNewId = cloneNodeWithNewIds(node, idGenerator)
|
|
359
|
+
if (stripReusable && 'reusable' in withNewId) {
|
|
360
|
+
delete (withNewId as unknown as Record<string, unknown>).reusable
|
|
361
|
+
}
|
|
362
|
+
if (offset !== 0) {
|
|
363
|
+
withNewId.x = (withNewId.x ?? 0) + offset
|
|
364
|
+
withNewId.y = (withNewId.y ?? 0) + offset
|
|
365
|
+
}
|
|
366
|
+
return withNewId
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Recursively rotate all children's relative positions and angles. */
|
|
371
|
+
export function rotateChildrenInPlace(
|
|
372
|
+
children: PenNode[],
|
|
373
|
+
angleDeltaDeg: number,
|
|
374
|
+
): PenNode[] {
|
|
375
|
+
const rad = (angleDeltaDeg * Math.PI) / 180
|
|
376
|
+
const cos = Math.cos(rad)
|
|
377
|
+
const sin = Math.sin(rad)
|
|
378
|
+
return children.map((child) => {
|
|
379
|
+
const x = child.x ?? 0
|
|
380
|
+
const y = child.y ?? 0
|
|
381
|
+
const updated: Record<string, unknown> = { ...child }
|
|
382
|
+
updated.x = x * cos - y * sin
|
|
383
|
+
updated.y = x * sin + y * cos
|
|
384
|
+
updated.rotation = ((child.rotation ?? 0) + angleDeltaDeg) % 360
|
|
385
|
+
if ('children' in child && child.children) {
|
|
386
|
+
updated.children = rotateChildrenInPlace(child.children, angleDeltaDeg)
|
|
387
|
+
}
|
|
388
|
+
return updated as unknown as PenNode
|
|
389
|
+
})
|
|
390
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively replace `$variable` references in a PenNode tree.
|
|
3
|
+
*
|
|
4
|
+
* Used when renaming or deleting a variable to keep the tree consistent.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PenNode } from '@zseven-w/pen-types'
|
|
8
|
+
import type { PenFill } from '@zseven-w/pen-types'
|
|
9
|
+
import type { VariableDefinition } from '@zseven-w/pen-types'
|
|
10
|
+
import { resolveVariableRef } from './resolve.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Replace all occurrences of `$oldRef` with `$newRef` in the node tree.
|
|
14
|
+
* When `newRef` is null (variable deleted), resolves to the concrete value.
|
|
15
|
+
*/
|
|
16
|
+
export function replaceVariableRefsInTree(
|
|
17
|
+
nodes: PenNode[],
|
|
18
|
+
oldRef: string,
|
|
19
|
+
newRef: string | null,
|
|
20
|
+
variables: Record<string, VariableDefinition>,
|
|
21
|
+
activeTheme: Record<string, string>,
|
|
22
|
+
): PenNode[] {
|
|
23
|
+
const oldToken = `$${oldRef}`
|
|
24
|
+
const replacement = newRef ? `$${newRef}` : undefined
|
|
25
|
+
|
|
26
|
+
function resolveOrReplace(val: string): string {
|
|
27
|
+
if (val !== oldToken) return val
|
|
28
|
+
if (replacement) return replacement
|
|
29
|
+
const resolved = resolveVariableRef(oldToken, variables, activeTheme)
|
|
30
|
+
return typeof resolved === 'string' ? resolved : val
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveOrReplaceNumeric(val: string | number): string | number {
|
|
34
|
+
if (typeof val !== 'string' || val !== oldToken) return val
|
|
35
|
+
if (replacement) return replacement
|
|
36
|
+
const resolved = resolveVariableRef(oldToken, variables, activeTheme)
|
|
37
|
+
return typeof resolved === 'number' ? resolved : val
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function replaceFills(fills: PenFill[] | string | undefined): PenFill[] | string | undefined {
|
|
41
|
+
if (!fills || typeof fills === 'string') return fills
|
|
42
|
+
return fills.map((f) => {
|
|
43
|
+
if (f.type === 'solid' && f.color === oldToken) {
|
|
44
|
+
return { ...f, color: resolveOrReplace(f.color) }
|
|
45
|
+
}
|
|
46
|
+
if (f.type === 'linear_gradient' || f.type === 'radial_gradient') {
|
|
47
|
+
const newStops = f.stops.map((s) =>
|
|
48
|
+
s.color === oldToken ? { ...s, color: resolveOrReplace(s.color) } : s,
|
|
49
|
+
)
|
|
50
|
+
return { ...f, stops: newStops }
|
|
51
|
+
}
|
|
52
|
+
return f
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function replaceInNode(node: PenNode): PenNode {
|
|
57
|
+
const out: Record<string, unknown> = { ...node }
|
|
58
|
+
let changed = false
|
|
59
|
+
|
|
60
|
+
// Opacity
|
|
61
|
+
if (typeof node.opacity === 'string' && node.opacity === oldToken) {
|
|
62
|
+
out.opacity = resolveOrReplaceNumeric(node.opacity)
|
|
63
|
+
changed = true
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Gap
|
|
67
|
+
if ('gap' in node && (node as unknown as Record<string, unknown>).gap === oldToken) {
|
|
68
|
+
out.gap = resolveOrReplaceNumeric(oldToken)
|
|
69
|
+
changed = true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Padding
|
|
73
|
+
if ('padding' in node && (node as unknown as Record<string, unknown>).padding === oldToken) {
|
|
74
|
+
out.padding = resolveOrReplaceNumeric(oldToken)
|
|
75
|
+
changed = true
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Fill
|
|
79
|
+
if ('fill' in node && (node as unknown as Record<string, unknown>).fill) {
|
|
80
|
+
const fills = (node as unknown as Record<string, unknown>).fill as PenFill[]
|
|
81
|
+
const newFills = replaceFills(fills)
|
|
82
|
+
if (newFills !== fills) {
|
|
83
|
+
out.fill = newFills
|
|
84
|
+
changed = true
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Stroke fill & thickness
|
|
89
|
+
if ('stroke' in node && (node as unknown as Record<string, unknown>).stroke) {
|
|
90
|
+
const stroke = (node as unknown as Record<string, unknown>).stroke as Record<string, unknown>
|
|
91
|
+
const newStroke = { ...stroke }
|
|
92
|
+
let strokeChanged = false
|
|
93
|
+
if (typeof stroke.thickness === 'string' && stroke.thickness === oldToken) {
|
|
94
|
+
newStroke.thickness = resolveOrReplaceNumeric(oldToken)
|
|
95
|
+
strokeChanged = true
|
|
96
|
+
}
|
|
97
|
+
if (stroke.fill) {
|
|
98
|
+
const newFill = replaceFills(stroke.fill as PenFill[])
|
|
99
|
+
if (newFill !== stroke.fill) {
|
|
100
|
+
newStroke.fill = newFill
|
|
101
|
+
strokeChanged = true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (strokeChanged) {
|
|
105
|
+
out.stroke = newStroke
|
|
106
|
+
changed = true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Effects
|
|
111
|
+
if ('effects' in node && Array.isArray((node as unknown as Record<string, unknown>).effects)) {
|
|
112
|
+
const effects = (node as unknown as Record<string, unknown>).effects as Record<string, unknown>[]
|
|
113
|
+
const newEffects = effects.map((e) => {
|
|
114
|
+
const ne = { ...e }
|
|
115
|
+
let ec = false
|
|
116
|
+
if (typeof e.color === 'string' && e.color === oldToken) {
|
|
117
|
+
ne.color = resolveOrReplace(e.color as string)
|
|
118
|
+
ec = true
|
|
119
|
+
}
|
|
120
|
+
for (const key of ['blur', 'offsetX', 'offsetY', 'spread']) {
|
|
121
|
+
if (typeof e[key] === 'string' && e[key] === oldToken) {
|
|
122
|
+
ne[key] = resolveOrReplaceNumeric(oldToken)
|
|
123
|
+
ec = true
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return ec ? ne : e
|
|
127
|
+
})
|
|
128
|
+
out.effects = newEffects
|
|
129
|
+
changed = true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Text content
|
|
133
|
+
if (node.type === 'text' && typeof node.content === 'string' && node.content === oldToken) {
|
|
134
|
+
out.content = resolveOrReplace(node.content)
|
|
135
|
+
changed = true
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Recurse children
|
|
139
|
+
if ('children' in node && (node as unknown as Record<string, unknown>).children) {
|
|
140
|
+
const children = (node as unknown as Record<string, unknown>).children as PenNode[]
|
|
141
|
+
out.children = replaceVariableRefsInTree(children, oldRef, newRef, variables, activeTheme)
|
|
142
|
+
changed = true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return changed ? (out as unknown as PenNode) : node
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return nodes.map(replaceInNode)
|
|
149
|
+
}
|