@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.
@@ -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
+ }