@zseven-w/pen-core 0.6.0 → 0.7.0
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 +18 -9
- package/package.json +5 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
package/src/tree-utils.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { nanoid } from 'nanoid'
|
|
2
|
-
import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types'
|
|
1
|
+
import { nanoid } from 'nanoid';
|
|
2
|
+
import type { PenDocument, PenNode, PenNodeBase, PenPage, RefNode } from '@zseven-w/pen-types';
|
|
3
3
|
|
|
4
|
-
export const DEFAULT_FRAME_ID = 'root-frame'
|
|
5
|
-
export const DEFAULT_PAGE_ID = 'page-1'
|
|
4
|
+
export const DEFAULT_FRAME_ID = 'root-frame';
|
|
5
|
+
export const DEFAULT_PAGE_ID = 'page-1';
|
|
6
6
|
|
|
7
7
|
export function createEmptyDocument(): PenDocument {
|
|
8
8
|
const children: PenNode[] = [
|
|
@@ -17,12 +17,12 @@ export function createEmptyDocument(): PenDocument {
|
|
|
17
17
|
fill: [{ type: 'solid', color: '#FFFFFF' }],
|
|
18
18
|
children: [],
|
|
19
19
|
},
|
|
20
|
-
]
|
|
20
|
+
];
|
|
21
21
|
return {
|
|
22
22
|
version: '1.0.0',
|
|
23
23
|
pages: [{ id: DEFAULT_PAGE_ID, name: 'Page 1', children }],
|
|
24
24
|
children: [],
|
|
25
|
-
}
|
|
25
|
+
};
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
@@ -30,23 +30,17 @@ export function createEmptyDocument(): PenDocument {
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
|
|
32
32
|
/** Get the active page object. */
|
|
33
|
-
export function getActivePage(
|
|
34
|
-
doc
|
|
35
|
-
activePageId
|
|
36
|
-
)
|
|
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]
|
|
33
|
+
export function getActivePage(doc: PenDocument, activePageId: string | null): PenPage | undefined {
|
|
34
|
+
if (!doc.pages || doc.pages.length === 0) return undefined;
|
|
35
|
+
if (!activePageId) return doc.pages[0];
|
|
36
|
+
return doc.pages.find((p) => p.id === activePageId) ?? doc.pages[0];
|
|
40
37
|
}
|
|
41
38
|
|
|
42
39
|
/** Get children for the active page (falls back to doc.children for legacy docs). */
|
|
43
|
-
export function getActivePageChildren(
|
|
44
|
-
doc
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const page = getActivePage(doc, activePageId)
|
|
48
|
-
if (page) return page.children
|
|
49
|
-
return doc.children
|
|
40
|
+
export function getActivePageChildren(doc: PenDocument, activePageId: string | null): PenNode[] {
|
|
41
|
+
const page = getActivePage(doc, activePageId);
|
|
42
|
+
if (page) return page.children;
|
|
43
|
+
return doc.children;
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
/** Return a new document with the active page's children replaced. */
|
|
@@ -56,29 +50,27 @@ export function setActivePageChildren(
|
|
|
56
50
|
children: PenNode[],
|
|
57
51
|
): PenDocument {
|
|
58
52
|
if (doc.pages && doc.pages.length > 0) {
|
|
59
|
-
const page = getActivePage(doc, activePageId)
|
|
60
|
-
if (!page) return { ...doc, children }
|
|
53
|
+
const page = getActivePage(doc, activePageId);
|
|
54
|
+
if (!page) return { ...doc, children };
|
|
61
55
|
return {
|
|
62
56
|
...doc,
|
|
63
|
-
pages: doc.pages.map((p) =>
|
|
64
|
-
|
|
65
|
-
),
|
|
66
|
-
}
|
|
57
|
+
pages: doc.pages.map((p) => (p.id === page.id ? { ...p, children } : p)),
|
|
58
|
+
};
|
|
67
59
|
}
|
|
68
|
-
return { ...doc, children }
|
|
60
|
+
return { ...doc, children };
|
|
69
61
|
}
|
|
70
62
|
|
|
71
63
|
/** Get all children across all pages (for cross-page component resolution). */
|
|
72
64
|
export function getAllChildren(doc: PenDocument): PenNode[] {
|
|
73
65
|
if (doc.pages && doc.pages.length > 0) {
|
|
74
|
-
return doc.pages.flatMap((p) => p.children)
|
|
66
|
+
return doc.pages.flatMap((p) => p.children);
|
|
75
67
|
}
|
|
76
|
-
return doc.children
|
|
68
|
+
return doc.children;
|
|
77
69
|
}
|
|
78
70
|
|
|
79
71
|
/** Migrate a legacy document (no pages) to page-based format. */
|
|
80
72
|
export function migrateToPages(doc: PenDocument): PenDocument {
|
|
81
|
-
if (doc.pages && doc.pages.length > 0) return doc
|
|
73
|
+
if (doc.pages && doc.pages.length > 0) return doc;
|
|
82
74
|
return {
|
|
83
75
|
...doc,
|
|
84
76
|
pages: [
|
|
@@ -89,17 +81,17 @@ export function migrateToPages(doc: PenDocument): PenDocument {
|
|
|
89
81
|
},
|
|
90
82
|
],
|
|
91
83
|
children: [],
|
|
92
|
-
}
|
|
84
|
+
};
|
|
93
85
|
}
|
|
94
86
|
|
|
95
87
|
/** Recursively ensure all nodes in the tree have an `id`. */
|
|
96
88
|
function ensureNodeIdsInTree(nodes: PenNode[]): void {
|
|
97
89
|
for (const node of nodes) {
|
|
98
90
|
if (!node.id) {
|
|
99
|
-
|
|
91
|
+
(node as PenNodeBase).id = nanoid();
|
|
100
92
|
}
|
|
101
93
|
if ('children' in node && node.children) {
|
|
102
|
-
ensureNodeIdsInTree(node.children)
|
|
94
|
+
ensureNodeIdsInTree(node.children);
|
|
103
95
|
}
|
|
104
96
|
}
|
|
105
97
|
}
|
|
@@ -108,42 +100,36 @@ function ensureNodeIdsInTree(nodes: PenNode[]): void {
|
|
|
108
100
|
export function ensureDocumentNodeIds(doc: PenDocument): PenDocument {
|
|
109
101
|
if (doc.pages) {
|
|
110
102
|
for (const page of doc.pages) {
|
|
111
|
-
if (!page.id) page.id = nanoid()
|
|
112
|
-
ensureNodeIdsInTree(page.children)
|
|
103
|
+
if (!page.id) page.id = nanoid();
|
|
104
|
+
ensureNodeIdsInTree(page.children);
|
|
113
105
|
}
|
|
114
106
|
}
|
|
115
|
-
ensureNodeIdsInTree(doc.children)
|
|
116
|
-
return doc
|
|
107
|
+
ensureNodeIdsInTree(doc.children);
|
|
108
|
+
return doc;
|
|
117
109
|
}
|
|
118
110
|
|
|
119
|
-
export function findNodeInTree(
|
|
120
|
-
nodes: PenNode[],
|
|
121
|
-
id: string,
|
|
122
|
-
): PenNode | undefined {
|
|
111
|
+
export function findNodeInTree(nodes: PenNode[], id: string): PenNode | undefined {
|
|
123
112
|
for (const node of nodes) {
|
|
124
|
-
if (node.id === id) return node
|
|
113
|
+
if (node.id === id) return node;
|
|
125
114
|
if ('children' in node && node.children) {
|
|
126
|
-
const found = findNodeInTree(node.children, id)
|
|
127
|
-
if (found) return found
|
|
115
|
+
const found = findNodeInTree(node.children, id);
|
|
116
|
+
if (found) return found;
|
|
128
117
|
}
|
|
129
118
|
}
|
|
130
|
-
return undefined
|
|
119
|
+
return undefined;
|
|
131
120
|
}
|
|
132
121
|
|
|
133
|
-
export function findParentInTree(
|
|
134
|
-
nodes: PenNode[],
|
|
135
|
-
id: string,
|
|
136
|
-
): PenNode | undefined {
|
|
122
|
+
export function findParentInTree(nodes: PenNode[], id: string): PenNode | undefined {
|
|
137
123
|
for (const node of nodes) {
|
|
138
124
|
if ('children' in node && node.children) {
|
|
139
125
|
for (const child of node.children) {
|
|
140
|
-
if (child.id === id) return node
|
|
126
|
+
if (child.id === id) return node;
|
|
141
127
|
}
|
|
142
|
-
const found = findParentInTree(node.children, id)
|
|
143
|
-
if (found) return found
|
|
128
|
+
const found = findParentInTree(node.children, id);
|
|
129
|
+
if (found) return found;
|
|
144
130
|
}
|
|
145
131
|
}
|
|
146
|
-
return undefined
|
|
132
|
+
return undefined;
|
|
147
133
|
}
|
|
148
134
|
|
|
149
135
|
export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
|
|
@@ -151,10 +137,10 @@ export function removeNodeFromTree(nodes: PenNode[], id: string): PenNode[] {
|
|
|
151
137
|
.filter((n) => n.id !== id)
|
|
152
138
|
.map((n) => {
|
|
153
139
|
if ('children' in n && n.children) {
|
|
154
|
-
return { ...n, children: removeNodeFromTree(n.children, id) }
|
|
140
|
+
return { ...n, children: removeNodeFromTree(n.children, id) };
|
|
155
141
|
}
|
|
156
|
-
return n
|
|
157
|
-
})
|
|
142
|
+
return n;
|
|
143
|
+
});
|
|
158
144
|
}
|
|
159
145
|
|
|
160
146
|
export function updateNodeInTree(
|
|
@@ -162,29 +148,53 @@ export function updateNodeInTree(
|
|
|
162
148
|
id: string,
|
|
163
149
|
updates: Partial<PenNode>,
|
|
164
150
|
): PenNode[] {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
151
|
+
if (Object.keys(updates).length === 0) return nodes;
|
|
152
|
+
|
|
153
|
+
const hasNodeChanges = (node: PenNode): boolean => {
|
|
154
|
+
const nodeRecord = node as unknown as Record<string, unknown>;
|
|
155
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
156
|
+
if (!Object.is(nodeRecord[key], value)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
168
159
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
160
|
+
return false;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
164
|
+
const node = nodes[i];
|
|
165
|
+
|
|
166
|
+
if (node.id === id) {
|
|
167
|
+
if (!hasNodeChanges(node)) return nodes;
|
|
168
|
+
const nextNodes = [...nodes];
|
|
169
|
+
nextNodes[i] = { ...node, ...updates } as PenNode;
|
|
170
|
+
return nextNodes;
|
|
174
171
|
}
|
|
175
|
-
|
|
176
|
-
|
|
172
|
+
|
|
173
|
+
if ('children' in node && node.children?.length) {
|
|
174
|
+
const nextChildren = updateNodeInTree(node.children, id, updates);
|
|
175
|
+
if (nextChildren !== node.children) {
|
|
176
|
+
const nextNodes = [...nodes];
|
|
177
|
+
nextNodes[i] = {
|
|
178
|
+
...node,
|
|
179
|
+
children: nextChildren,
|
|
180
|
+
} as PenNode;
|
|
181
|
+
return nextNodes;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return nodes;
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
export function flattenNodes(nodes: PenNode[]): PenNode[] {
|
|
180
|
-
const result: PenNode[] = []
|
|
190
|
+
const result: PenNode[] = [];
|
|
181
191
|
for (const node of nodes) {
|
|
182
|
-
result.push(node)
|
|
192
|
+
result.push(node);
|
|
183
193
|
if ('children' in node && node.children) {
|
|
184
|
-
result.push(...flattenNodes(node.children))
|
|
194
|
+
result.push(...flattenNodes(node.children));
|
|
185
195
|
}
|
|
186
196
|
}
|
|
187
|
-
return result
|
|
197
|
+
return result;
|
|
188
198
|
}
|
|
189
199
|
|
|
190
200
|
export function insertNodeInTree(
|
|
@@ -194,47 +204,43 @@ export function insertNodeInTree(
|
|
|
194
204
|
index?: number,
|
|
195
205
|
): PenNode[] {
|
|
196
206
|
if (parentId === null) {
|
|
197
|
-
const arr = [...nodes]
|
|
207
|
+
const arr = [...nodes];
|
|
198
208
|
if (index !== undefined) {
|
|
199
|
-
arr.splice(index, 0, node)
|
|
209
|
+
arr.splice(index, 0, node);
|
|
200
210
|
} else {
|
|
201
|
-
arr.push(node)
|
|
211
|
+
arr.push(node);
|
|
202
212
|
}
|
|
203
|
-
return arr
|
|
213
|
+
return arr;
|
|
204
214
|
}
|
|
205
215
|
|
|
206
216
|
return nodes.map((n) => {
|
|
207
217
|
if (n.id === parentId) {
|
|
208
|
-
const children = 'children' in n && n.children ? [...n.children] : []
|
|
218
|
+
const children = 'children' in n && n.children ? [...n.children] : [];
|
|
209
219
|
if (index !== undefined) {
|
|
210
|
-
children.splice(index, 0, node)
|
|
220
|
+
children.splice(index, 0, node);
|
|
211
221
|
} else {
|
|
212
|
-
children.push(node)
|
|
222
|
+
children.push(node);
|
|
213
223
|
}
|
|
214
|
-
return { ...n, children } as PenNode
|
|
224
|
+
return { ...n, children } as PenNode;
|
|
215
225
|
}
|
|
216
226
|
if ('children' in n && n.children) {
|
|
217
227
|
return {
|
|
218
228
|
...n,
|
|
219
229
|
children: insertNodeInTree(n.children, parentId, node, index),
|
|
220
|
-
} as PenNode
|
|
230
|
+
} as PenNode;
|
|
221
231
|
}
|
|
222
|
-
return n
|
|
223
|
-
})
|
|
232
|
+
return n;
|
|
233
|
+
});
|
|
224
234
|
}
|
|
225
235
|
|
|
226
|
-
export function isDescendantOf(
|
|
227
|
-
nodes
|
|
228
|
-
|
|
229
|
-
ancestorId: string,
|
|
230
|
-
): boolean {
|
|
231
|
-
const ancestor = findNodeInTree(nodes, ancestorId)
|
|
232
|
-
if (!ancestor || !('children' in ancestor) || !ancestor.children) return false
|
|
236
|
+
export function isDescendantOf(nodes: PenNode[], nodeId: string, ancestorId: string): boolean {
|
|
237
|
+
const ancestor = findNodeInTree(nodes, ancestorId);
|
|
238
|
+
if (!ancestor || !('children' in ancestor) || !ancestor.children) return false;
|
|
233
239
|
for (const child of ancestor.children) {
|
|
234
|
-
if (child.id === nodeId) return true
|
|
235
|
-
if (isDescendantOf([child], nodeId, child.id)) return true
|
|
240
|
+
if (child.id === nodeId) return true;
|
|
241
|
+
if (isDescendantOf([child], nodeId, child.id)) return true;
|
|
236
242
|
}
|
|
237
|
-
return false
|
|
243
|
+
return false;
|
|
238
244
|
}
|
|
239
245
|
|
|
240
246
|
/** Resolve the bounding box of a node, falling back to its referenced component for RefNodes. */
|
|
@@ -242,18 +248,18 @@ export function getNodeBounds(
|
|
|
242
248
|
node: PenNode,
|
|
243
249
|
allNodes: PenNode[],
|
|
244
250
|
): { x: number; y: number; w: number; h: number } {
|
|
245
|
-
const x = node.x ?? 0
|
|
246
|
-
const y = node.y ?? 0
|
|
247
|
-
let w =
|
|
248
|
-
let h =
|
|
251
|
+
const x = node.x ?? 0;
|
|
252
|
+
const y = node.y ?? 0;
|
|
253
|
+
let w = 'width' in node && typeof node.width === 'number' ? node.width : 0;
|
|
254
|
+
let h = 'height' in node && typeof node.height === 'number' ? node.height : 0;
|
|
249
255
|
if (node.type === 'ref' && !w) {
|
|
250
|
-
const refComp = findNodeInTree(allNodes, (node as RefNode).ref)
|
|
256
|
+
const refComp = findNodeInTree(allNodes, (node as RefNode).ref);
|
|
251
257
|
if (refComp) {
|
|
252
|
-
w =
|
|
253
|
-
h =
|
|
258
|
+
w = 'width' in refComp && typeof refComp.width === 'number' ? refComp.width : 100;
|
|
259
|
+
h = 'height' in refComp && typeof refComp.height === 'number' ? refComp.height : 100;
|
|
254
260
|
}
|
|
255
261
|
}
|
|
256
|
-
return { x, y, w: w || 100, h: h || 100 }
|
|
262
|
+
return { x, y, w: w || 100, h: h || 100 };
|
|
257
263
|
}
|
|
258
264
|
|
|
259
265
|
/**
|
|
@@ -270,36 +276,36 @@ export function findClearX(
|
|
|
270
276
|
allNodes: PenNode[],
|
|
271
277
|
gap = 20,
|
|
272
278
|
): number {
|
|
273
|
-
const proposedW = sourceW
|
|
274
|
-
let proposedX = sourceX + sourceW + gap
|
|
279
|
+
const proposedW = sourceW;
|
|
280
|
+
let proposedX = sourceX + sourceW + gap;
|
|
275
281
|
|
|
276
|
-
const siblingBounds: { x: number; y: number; w: number; h: number }[] = []
|
|
282
|
+
const siblingBounds: { x: number; y: number; w: number; h: number }[] = [];
|
|
277
283
|
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)
|
|
284
|
+
if (sib.id === excludeId) continue;
|
|
285
|
+
const b = getNodeBounds(sib, allNodes);
|
|
286
|
+
if (b.w > 0 && b.h > 0) siblingBounds.push(b);
|
|
281
287
|
}
|
|
282
288
|
|
|
283
|
-
let maxAttempts = 100
|
|
289
|
+
let maxAttempts = 100;
|
|
284
290
|
while (maxAttempts-- > 0) {
|
|
285
291
|
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
|
+
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x;
|
|
293
|
+
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y;
|
|
294
|
+
return overlapX && overlapY;
|
|
295
|
+
});
|
|
296
|
+
if (!hasOverlap) break;
|
|
297
|
+
let maxRight = proposedX;
|
|
292
298
|
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
|
|
299
|
+
const overlapX = proposedX < b.x + b.w && proposedX + proposedW > b.x;
|
|
300
|
+
const overlapY = proposedY < b.y + b.h && proposedY + proposedH > b.y;
|
|
295
301
|
if (overlapX && overlapY && b.x + b.w > maxRight) {
|
|
296
|
-
maxRight = b.x + b.w
|
|
302
|
+
maxRight = b.x + b.w;
|
|
297
303
|
}
|
|
298
304
|
}
|
|
299
|
-
proposedX = maxRight + gap
|
|
305
|
+
proposedX = maxRight + gap;
|
|
300
306
|
}
|
|
301
307
|
|
|
302
|
-
return proposedX
|
|
308
|
+
return proposedX;
|
|
303
309
|
}
|
|
304
310
|
|
|
305
311
|
/** Recursively scale all children's relative positions and sizes. */
|
|
@@ -309,20 +315,20 @@ export function scaleChildrenInPlace(
|
|
|
309
315
|
scaleY: number,
|
|
310
316
|
): PenNode[] {
|
|
311
317
|
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
|
|
318
|
+
const updated: Record<string, unknown> = { ...child };
|
|
319
|
+
if (child.x !== undefined) updated.x = child.x * scaleX;
|
|
320
|
+
if (child.y !== undefined) updated.y = child.y * scaleY;
|
|
315
321
|
if ('width' in child && typeof child.width === 'number') {
|
|
316
|
-
updated.width = child.width * scaleX
|
|
322
|
+
updated.width = child.width * scaleX;
|
|
317
323
|
}
|
|
318
324
|
if ('height' in child && typeof child.height === 'number') {
|
|
319
|
-
updated.height = child.height * scaleY
|
|
325
|
+
updated.height = child.height * scaleY;
|
|
320
326
|
}
|
|
321
327
|
if ('children' in child && child.children) {
|
|
322
|
-
updated.children = scaleChildrenInPlace(child.children, scaleX, scaleY)
|
|
328
|
+
updated.children = scaleChildrenInPlace(child.children, scaleX, scaleY);
|
|
323
329
|
}
|
|
324
|
-
return updated as unknown as PenNode
|
|
325
|
-
})
|
|
330
|
+
return updated as unknown as PenNode;
|
|
331
|
+
});
|
|
326
332
|
}
|
|
327
333
|
|
|
328
334
|
// ---------------------------------------------------------------------------
|
|
@@ -331,21 +337,16 @@ export function scaleChildrenInPlace(
|
|
|
331
337
|
|
|
332
338
|
/** Deep-clone a node tree preserving all IDs. */
|
|
333
339
|
export function deepCloneNode<T extends PenNode>(node: T): T {
|
|
334
|
-
return structuredClone(node)
|
|
340
|
+
return structuredClone(node);
|
|
335
341
|
}
|
|
336
342
|
|
|
337
343
|
/** 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
|
|
344
|
+
export function cloneNodeWithNewIds(node: PenNode, idGenerator: () => string = nanoid): PenNode {
|
|
345
|
+
const cloned = { ...node, id: idGenerator() } as PenNode;
|
|
343
346
|
if ('children' in cloned && cloned.children) {
|
|
344
|
-
cloned.children = cloned.children.map((c) =>
|
|
345
|
-
cloneNodeWithNewIds(c, idGenerator),
|
|
346
|
-
)
|
|
347
|
+
cloned.children = cloned.children.map((c) => cloneNodeWithNewIds(c, idGenerator));
|
|
347
348
|
}
|
|
348
|
-
return cloned
|
|
349
|
+
return cloned;
|
|
349
350
|
}
|
|
350
351
|
|
|
351
352
|
/** Clone multiple nodes with new IDs. Optionally strip `reusable` flag and apply position offset. */
|
|
@@ -353,38 +354,59 @@ export function cloneNodesWithNewIds(
|
|
|
353
354
|
nodes: PenNode[],
|
|
354
355
|
options: { offset?: number; stripReusable?: boolean; idGenerator?: () => string } = {},
|
|
355
356
|
): PenNode[] {
|
|
356
|
-
const { offset = 0, stripReusable = true, idGenerator = nanoid } = options
|
|
357
|
+
const { offset = 0, stripReusable = true, idGenerator = nanoid } = options;
|
|
357
358
|
return structuredClone(nodes).map((node) => {
|
|
358
|
-
const withNewId = cloneNodeWithNewIds(node, idGenerator)
|
|
359
|
+
const withNewId = cloneNodeWithNewIds(node, idGenerator);
|
|
359
360
|
if (stripReusable && 'reusable' in withNewId) {
|
|
360
|
-
delete (withNewId as unknown as Record<string, unknown>).reusable
|
|
361
|
+
delete (withNewId as unknown as Record<string, unknown>).reusable;
|
|
361
362
|
}
|
|
362
363
|
if (offset !== 0) {
|
|
363
|
-
withNewId.x = (withNewId.x ?? 0) + offset
|
|
364
|
-
withNewId.y = (withNewId.y ?? 0) + offset
|
|
364
|
+
withNewId.x = (withNewId.x ?? 0) + offset;
|
|
365
|
+
withNewId.y = (withNewId.y ?? 0) + offset;
|
|
365
366
|
}
|
|
366
|
-
return withNewId
|
|
367
|
-
})
|
|
367
|
+
return withNewId;
|
|
368
|
+
});
|
|
368
369
|
}
|
|
369
370
|
|
|
370
371
|
/** Recursively rotate all children's relative positions and angles. */
|
|
371
|
-
export function rotateChildrenInPlace(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
const rad = (angleDeltaDeg * Math.PI) / 180
|
|
376
|
-
const cos = Math.cos(rad)
|
|
377
|
-
const sin = Math.sin(rad)
|
|
372
|
+
export function rotateChildrenInPlace(children: PenNode[], angleDeltaDeg: number): PenNode[] {
|
|
373
|
+
const rad = (angleDeltaDeg * Math.PI) / 180;
|
|
374
|
+
const cos = Math.cos(rad);
|
|
375
|
+
const sin = Math.sin(rad);
|
|
378
376
|
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
|
|
377
|
+
const x = child.x ?? 0;
|
|
378
|
+
const y = child.y ?? 0;
|
|
379
|
+
const updated: Record<string, unknown> = { ...child };
|
|
380
|
+
updated.x = x * cos - y * sin;
|
|
381
|
+
updated.y = x * sin + y * cos;
|
|
382
|
+
updated.rotation = ((child.rotation ?? 0) + angleDeltaDeg) % 360;
|
|
385
383
|
if ('children' in child && child.children) {
|
|
386
|
-
updated.children = rotateChildrenInPlace(child.children, angleDeltaDeg)
|
|
384
|
+
updated.children = rotateChildrenInPlace(child.children, angleDeltaDeg);
|
|
387
385
|
}
|
|
388
|
-
return updated as unknown as PenNode
|
|
389
|
-
})
|
|
386
|
+
return updated as unknown as PenNode;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Recursively render a node tree summary for AI prompts.
|
|
392
|
+
* Shows node IDs, types, names, dimensions, roles, and child counts with indentation.
|
|
393
|
+
*/
|
|
394
|
+
export function nodeTreeToSummary(nodes: PenNode[], depth: number = 0): string {
|
|
395
|
+
if (nodes.length === 0) return '';
|
|
396
|
+
|
|
397
|
+
const indent = ' '.repeat(depth);
|
|
398
|
+
return nodes
|
|
399
|
+
.map((node) => {
|
|
400
|
+
const n = node as unknown as Record<string, unknown>;
|
|
401
|
+
const dims = `${n.width ?? '?'}x${n.height ?? '?'}`;
|
|
402
|
+
const childCount = (n.children as PenNode[] | undefined)?.length ?? 0;
|
|
403
|
+
const role = n.role ? ` [${n.role}]` : '';
|
|
404
|
+
const line = `${indent}- [${node.id}] ${node.type} "${node.name ?? ''}" (${dims})${role}${childCount > 0 ? ` [${childCount} children]` : ''}`;
|
|
405
|
+
const children = n.children as PenNode[] | undefined;
|
|
406
|
+
if (children && children.length > 0) {
|
|
407
|
+
return line + '\n' + nodeTreeToSummary(children, depth + 1);
|
|
408
|
+
}
|
|
409
|
+
return line;
|
|
410
|
+
})
|
|
411
|
+
.join('\n');
|
|
390
412
|
}
|