@ankhorage/studio 0.0.3 → 0.0.5
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 +11 -1
- package/dist/index.d.ts +91 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +810 -0
- package/dist/index.js.map +1 -1
- package/dist/manifestState.d.ts +88 -0
- package/dist/manifestState.d.ts.map +1 -0
- package/dist/manifestState.js +858 -0
- package/dist/manifestState.js.map +1 -0
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export const STUDIO_PACKAGE_BOUNDARY = {
|
|
|
5
5
|
'Studio product contracts',
|
|
6
6
|
'Studio manifest editing contracts',
|
|
7
7
|
'Studio command and event contracts',
|
|
8
|
+
'Studio authoring model helpers',
|
|
8
9
|
],
|
|
9
10
|
consumes: [
|
|
10
11
|
'@ankhorage/contracts',
|
|
@@ -34,5 +35,814 @@ export const STUDIO_PUBLIC_CONTRACTS = [
|
|
|
34
35
|
'ActionDefinition',
|
|
35
36
|
'StudioCommand',
|
|
36
37
|
'StudioEvent',
|
|
38
|
+
'StudioComponentMetaRegistry',
|
|
39
|
+
'ACTION_REGISTRY',
|
|
40
|
+
'TPL_SCREEN_EMPTY',
|
|
41
|
+
'resolveDefaultInsertPlacement',
|
|
42
|
+
'buildInsertCatalogEntries',
|
|
37
43
|
];
|
|
44
|
+
export const ACTION_REGISTRY = {
|
|
45
|
+
navigate: {
|
|
46
|
+
type: 'navigate',
|
|
47
|
+
label: 'Navigate',
|
|
48
|
+
description: 'Navigate to another screen or route',
|
|
49
|
+
requiresPayload: true,
|
|
50
|
+
payloadSchema: {
|
|
51
|
+
route: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
label: 'Route',
|
|
54
|
+
required: true,
|
|
55
|
+
},
|
|
56
|
+
params: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
label: 'Parameters',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
alert: {
|
|
63
|
+
type: 'alert',
|
|
64
|
+
label: 'Alert',
|
|
65
|
+
description: 'Show an alert dialog',
|
|
66
|
+
requiresPayload: false,
|
|
67
|
+
payloadSchema: {
|
|
68
|
+
message: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
label: 'Message',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
console: {
|
|
75
|
+
type: 'console',
|
|
76
|
+
label: 'Console Log',
|
|
77
|
+
description: 'Log a message to the console',
|
|
78
|
+
requiresPayload: false,
|
|
79
|
+
},
|
|
80
|
+
toggleDarkMode: {
|
|
81
|
+
type: 'toggleDarkMode',
|
|
82
|
+
label: 'Toggle Dark Mode',
|
|
83
|
+
description: 'Toggle between light and dark theme modes',
|
|
84
|
+
requiresPayload: false,
|
|
85
|
+
},
|
|
86
|
+
setLanguage: {
|
|
87
|
+
type: 'setLanguage',
|
|
88
|
+
label: 'Set Language',
|
|
89
|
+
description: 'Change the application language',
|
|
90
|
+
requiresPayload: true,
|
|
91
|
+
payloadSchema: {
|
|
92
|
+
locale: {
|
|
93
|
+
type: 'string',
|
|
94
|
+
label: 'Locale',
|
|
95
|
+
required: true,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
search: {
|
|
100
|
+
type: 'search',
|
|
101
|
+
label: 'Search',
|
|
102
|
+
description: 'Search for content by text and metadata',
|
|
103
|
+
requiresPayload: true,
|
|
104
|
+
payloadSchema: {
|
|
105
|
+
query: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
label: 'Search Query',
|
|
108
|
+
required: true,
|
|
109
|
+
},
|
|
110
|
+
scope: {
|
|
111
|
+
type: 'string',
|
|
112
|
+
label: 'Search Scope',
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
filter: {
|
|
117
|
+
type: 'filter',
|
|
118
|
+
label: 'Filter',
|
|
119
|
+
description: 'Filter content by key-value pairs',
|
|
120
|
+
requiresPayload: true,
|
|
121
|
+
payloadSchema: {
|
|
122
|
+
filterKey: {
|
|
123
|
+
type: 'string',
|
|
124
|
+
label: 'Filter Key',
|
|
125
|
+
required: true,
|
|
126
|
+
},
|
|
127
|
+
filterValue: {
|
|
128
|
+
type: 'string',
|
|
129
|
+
label: 'Filter Value',
|
|
130
|
+
required: true,
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
export const TPL_SCREEN_EMPTY = {
|
|
136
|
+
id: 'tpl-screen-empty',
|
|
137
|
+
type: 'Screen',
|
|
138
|
+
props: {
|
|
139
|
+
width: 'wide',
|
|
140
|
+
},
|
|
141
|
+
children: [
|
|
142
|
+
{
|
|
143
|
+
id: 'tpl-screen-empty-header',
|
|
144
|
+
type: 'SectionHeader',
|
|
145
|
+
props: {
|
|
146
|
+
title: 'New Screen',
|
|
147
|
+
description: 'Start authoring with ZORA layouts and patterns.',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'tpl-screen-empty-section',
|
|
152
|
+
type: 'ScreenSection',
|
|
153
|
+
props: {
|
|
154
|
+
title: 'Build the first section',
|
|
155
|
+
description: 'Insert panels, forms, or content patterns to start authoring.',
|
|
156
|
+
},
|
|
157
|
+
children: [
|
|
158
|
+
{
|
|
159
|
+
id: 'tpl-screen-empty-state',
|
|
160
|
+
type: 'EmptyState',
|
|
161
|
+
props: {
|
|
162
|
+
title: 'Canvas is ready',
|
|
163
|
+
description: 'Use Insert to add components and layouts.',
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'tpl-screen-empty-action',
|
|
170
|
+
type: 'Button',
|
|
171
|
+
props: {
|
|
172
|
+
children: 'Add first section',
|
|
173
|
+
tone: 'primary',
|
|
174
|
+
emphasis: 'solid',
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* Generates a unique ID for Studio-authored nodes and entities.
|
|
181
|
+
*/
|
|
182
|
+
export const generateStudioId = (prefix) => {
|
|
183
|
+
const timestamp = Date.now().toString(36);
|
|
184
|
+
const random = Math.random().toString(36).substring(2, 11);
|
|
185
|
+
const id = `${timestamp}-${random}`;
|
|
186
|
+
return prefix ? `${prefix.toLowerCase()}-${id}` : id;
|
|
187
|
+
};
|
|
188
|
+
export const cloneWithNewIds = (node, createId = generateStudioId) => {
|
|
189
|
+
const clonedNode = {
|
|
190
|
+
...node,
|
|
191
|
+
id: createId(node.type),
|
|
192
|
+
props: node.props ? { ...node.props } : node.props,
|
|
193
|
+
};
|
|
194
|
+
if (node.children) {
|
|
195
|
+
clonedNode.children = node.children.map((child) => cloneWithNewIds(child, createId));
|
|
196
|
+
}
|
|
197
|
+
return clonedNode;
|
|
198
|
+
};
|
|
199
|
+
export const findNodeById = (root, id) => {
|
|
200
|
+
if (root.id === id)
|
|
201
|
+
return root;
|
|
202
|
+
if (!root.children)
|
|
203
|
+
return null;
|
|
204
|
+
for (const child of root.children) {
|
|
205
|
+
const found = findNodeById(child, id);
|
|
206
|
+
if (found)
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
};
|
|
211
|
+
export const updateNodeInTree = (root, id, newProps) => {
|
|
212
|
+
if (root.id === id) {
|
|
213
|
+
const { alias, style, ...rest } = newProps;
|
|
214
|
+
const aliasUpdate = typeof alias === 'string' ? { alias } : {};
|
|
215
|
+
const styleUpdate = isStyleRecord(style) ? { style } : {};
|
|
216
|
+
return {
|
|
217
|
+
...root,
|
|
218
|
+
...aliasUpdate,
|
|
219
|
+
...styleUpdate,
|
|
220
|
+
props: { ...(root.props ?? {}), ...rest },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
if (!root.children) {
|
|
224
|
+
return root;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
...root,
|
|
228
|
+
children: root.children.map((child) => updateNodeInTree(child, id, newProps)),
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
export const removeNodeFromTree = (root, nodeId) => {
|
|
232
|
+
if (root.id === nodeId)
|
|
233
|
+
return null;
|
|
234
|
+
if (!root.children) {
|
|
235
|
+
return root;
|
|
236
|
+
}
|
|
237
|
+
const filteredChildren = root.children.filter((child) => child.id !== nodeId);
|
|
238
|
+
if (filteredChildren.length !== root.children.length) {
|
|
239
|
+
return { ...root, children: filteredChildren };
|
|
240
|
+
}
|
|
241
|
+
const nextChildren = root.children.map((child) => removeNodeFromTree(child, nodeId) ?? child);
|
|
242
|
+
const hasChanged = nextChildren.some((child, index) => child !== root.children?.[index]);
|
|
243
|
+
return hasChanged ? { ...root, children: nextChildren } : root;
|
|
244
|
+
};
|
|
245
|
+
export const addNodeToTree = (args) => {
|
|
246
|
+
const { root, targetId, newNode, componentMeta, mode = 'append' } = args;
|
|
247
|
+
if (root.id === targetId) {
|
|
248
|
+
if (!canAcceptChild({ parentType: root.type, childType: newNode.type, componentMeta })) {
|
|
249
|
+
return root;
|
|
250
|
+
}
|
|
251
|
+
const children = root.children ?? [];
|
|
252
|
+
return {
|
|
253
|
+
...root,
|
|
254
|
+
children: mode === 'prepend' ? [newNode, ...children] : [...children, newNode],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
if (!root.children) {
|
|
258
|
+
return root;
|
|
259
|
+
}
|
|
260
|
+
const nextChildren = root.children.map((child) => addNodeToTree({ root: child, targetId, newNode, componentMeta, mode }));
|
|
261
|
+
const hasChanged = nextChildren.some((child, index) => child !== root.children?.[index]);
|
|
262
|
+
return hasChanged ? { ...root, children: nextChildren } : root;
|
|
263
|
+
};
|
|
264
|
+
export const moveNodeInTree = (root, nodeId, direction) => {
|
|
265
|
+
if (root.id === nodeId || !root.children)
|
|
266
|
+
return root;
|
|
267
|
+
const index = root.children.findIndex((child) => child.id === nodeId);
|
|
268
|
+
if (index !== -1) {
|
|
269
|
+
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
|
270
|
+
if (targetIndex < 0 || targetIndex >= root.children.length) {
|
|
271
|
+
return root;
|
|
272
|
+
}
|
|
273
|
+
const currentNode = root.children[index];
|
|
274
|
+
const targetNode = root.children[targetIndex];
|
|
275
|
+
if (!currentNode || !targetNode) {
|
|
276
|
+
return root;
|
|
277
|
+
}
|
|
278
|
+
const nextChildren = [...root.children];
|
|
279
|
+
nextChildren[index] = targetNode;
|
|
280
|
+
nextChildren[targetIndex] = currentNode;
|
|
281
|
+
return { ...root, children: nextChildren };
|
|
282
|
+
}
|
|
283
|
+
const nextChildren = root.children.map((child) => moveNodeInTree(child, nodeId, direction));
|
|
284
|
+
const hasChanged = nextChildren.some((child, childIndex) => child !== root.children?.[childIndex]);
|
|
285
|
+
return hasChanged ? { ...root, children: nextChildren } : root;
|
|
286
|
+
};
|
|
287
|
+
function findNodeWithParent(root, nodeId) {
|
|
288
|
+
if (root.id === nodeId) {
|
|
289
|
+
return { node: root, parent: null, index: -1 };
|
|
290
|
+
}
|
|
291
|
+
const visit = (node) => {
|
|
292
|
+
const children = node.children ?? [];
|
|
293
|
+
for (const [index, child] of children.entries()) {
|
|
294
|
+
if (child.id === nodeId) {
|
|
295
|
+
return { node: child, parent: node, index };
|
|
296
|
+
}
|
|
297
|
+
const nested = visit(child);
|
|
298
|
+
if (nested) {
|
|
299
|
+
return nested;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
};
|
|
304
|
+
return visit(root);
|
|
305
|
+
}
|
|
306
|
+
function isDescendantNode(node, descendantId) {
|
|
307
|
+
const children = node.children ?? [];
|
|
308
|
+
for (const child of children) {
|
|
309
|
+
if (child.id === descendantId || isDescendantNode(child, descendantId)) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
function removeNodeForMove(args) {
|
|
316
|
+
const { node, nodeId } = args;
|
|
317
|
+
const children = node.children ?? [];
|
|
318
|
+
const directIndex = children.findIndex((child) => child.id === nodeId);
|
|
319
|
+
if (directIndex !== -1) {
|
|
320
|
+
const removedNode = children[directIndex];
|
|
321
|
+
if (!removedNode) {
|
|
322
|
+
return { node, removedNode: null };
|
|
323
|
+
}
|
|
324
|
+
return {
|
|
325
|
+
node: {
|
|
326
|
+
...node,
|
|
327
|
+
children: children.filter((child) => child.id !== nodeId),
|
|
328
|
+
},
|
|
329
|
+
removedNode,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
const nextChildren = [];
|
|
333
|
+
let removedNode = null;
|
|
334
|
+
for (const child of children) {
|
|
335
|
+
if (removedNode) {
|
|
336
|
+
nextChildren.push(child);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const { node: nextChild, removedNode: nextRemovedNode } = removeNodeForMove({
|
|
340
|
+
node: child,
|
|
341
|
+
nodeId,
|
|
342
|
+
});
|
|
343
|
+
if (nextRemovedNode) {
|
|
344
|
+
removedNode = nextRemovedNode;
|
|
345
|
+
}
|
|
346
|
+
nextChildren.push(nextChild);
|
|
347
|
+
}
|
|
348
|
+
if (!removedNode) {
|
|
349
|
+
return { node, removedNode: null };
|
|
350
|
+
}
|
|
351
|
+
return {
|
|
352
|
+
node: {
|
|
353
|
+
...node,
|
|
354
|
+
children: nextChildren,
|
|
355
|
+
},
|
|
356
|
+
removedNode,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
export function canAcceptChild(args) {
|
|
360
|
+
const { parentType, childType, componentMeta } = args;
|
|
361
|
+
const meta = componentMeta[parentType];
|
|
362
|
+
if (!meta)
|
|
363
|
+
return false;
|
|
364
|
+
return meta.allowedChildren.includes(childType);
|
|
365
|
+
}
|
|
366
|
+
export function validateNodePlacement(args) {
|
|
367
|
+
const { root, placement, childType, componentMeta } = args;
|
|
368
|
+
const parent = findNodeById(root, placement.parentId);
|
|
369
|
+
if (!parent) {
|
|
370
|
+
return {
|
|
371
|
+
ok: false,
|
|
372
|
+
reason: {
|
|
373
|
+
code: 'missing-parent',
|
|
374
|
+
message: `Parent node ${placement.parentId} was not found.`,
|
|
375
|
+
},
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
if (!canAcceptChild({ parentType: parent.type, childType, componentMeta })) {
|
|
379
|
+
return {
|
|
380
|
+
ok: false,
|
|
381
|
+
reason: {
|
|
382
|
+
code: 'child-not-allowed',
|
|
383
|
+
message: `Parent ${parent.type} does not allow ${childType} children.`,
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const children = parent.children ?? [];
|
|
388
|
+
if (placement.referenceId) {
|
|
389
|
+
const hasReference = children.some((child) => child.id === placement.referenceId);
|
|
390
|
+
if (!hasReference) {
|
|
391
|
+
return {
|
|
392
|
+
ok: false,
|
|
393
|
+
reason: {
|
|
394
|
+
code: 'missing-target',
|
|
395
|
+
message: `Reference node ${placement.referenceId} was not found under ${parent.id}.`,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (placement.index < 0 || placement.index > children.length) {
|
|
401
|
+
return {
|
|
402
|
+
ok: false,
|
|
403
|
+
reason: {
|
|
404
|
+
code: 'invalid-index',
|
|
405
|
+
message: `Index ${placement.index} is out of bounds for ${parent.id}.`,
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
return { ok: true };
|
|
410
|
+
}
|
|
411
|
+
export function resolveInsertPlacement(args) {
|
|
412
|
+
const { root, targetNodeId, childType, componentMeta, kind } = args;
|
|
413
|
+
const target = findNodeWithParent(root, targetNodeId);
|
|
414
|
+
if (!target) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
reason: {
|
|
418
|
+
code: 'missing-target',
|
|
419
|
+
message: `Target node ${targetNodeId} was not found.`,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
if (kind === 'inside') {
|
|
424
|
+
const parent = target.node;
|
|
425
|
+
const children = parent.children ?? [];
|
|
426
|
+
const placement = {
|
|
427
|
+
parentId: parent.id,
|
|
428
|
+
index: children.length,
|
|
429
|
+
kind,
|
|
430
|
+
};
|
|
431
|
+
const validation = validateNodePlacement({ root, placement, childType, componentMeta });
|
|
432
|
+
if (!validation.ok) {
|
|
433
|
+
return validation;
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, placement };
|
|
436
|
+
}
|
|
437
|
+
const { parent } = target;
|
|
438
|
+
if (!parent) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
reason: {
|
|
442
|
+
code: 'missing-parent',
|
|
443
|
+
message: `Target node ${targetNodeId} has no parent.`,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const index = kind === 'before' ? target.index : target.index + 1;
|
|
448
|
+
const placement = {
|
|
449
|
+
parentId: parent.id,
|
|
450
|
+
index,
|
|
451
|
+
kind,
|
|
452
|
+
referenceId: target.node.id,
|
|
453
|
+
};
|
|
454
|
+
const validation = validateNodePlacement({ root, placement, childType, componentMeta });
|
|
455
|
+
if (!validation.ok) {
|
|
456
|
+
return validation;
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, placement };
|
|
459
|
+
}
|
|
460
|
+
export function resolveDefaultInsertPlacement(args) {
|
|
461
|
+
const { root, selectedNodeId, childType, componentMeta } = args;
|
|
462
|
+
if (selectedNodeId) {
|
|
463
|
+
const inside = resolveInsertPlacement({
|
|
464
|
+
root,
|
|
465
|
+
targetNodeId: selectedNodeId,
|
|
466
|
+
childType,
|
|
467
|
+
componentMeta,
|
|
468
|
+
kind: 'inside',
|
|
469
|
+
});
|
|
470
|
+
if (inside.ok)
|
|
471
|
+
return inside;
|
|
472
|
+
const after = resolveInsertPlacement({
|
|
473
|
+
root,
|
|
474
|
+
targetNodeId: selectedNodeId,
|
|
475
|
+
childType,
|
|
476
|
+
componentMeta,
|
|
477
|
+
kind: 'after',
|
|
478
|
+
});
|
|
479
|
+
if (after.ok)
|
|
480
|
+
return after;
|
|
481
|
+
}
|
|
482
|
+
const atRoot = resolveInsertPlacement({
|
|
483
|
+
root,
|
|
484
|
+
targetNodeId: root.id,
|
|
485
|
+
childType,
|
|
486
|
+
componentMeta,
|
|
487
|
+
kind: 'inside',
|
|
488
|
+
});
|
|
489
|
+
if (atRoot.ok)
|
|
490
|
+
return atRoot;
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
reason: {
|
|
494
|
+
code: 'no-valid-target',
|
|
495
|
+
message: `No valid insertion target found for ${childType}.`,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function insertChildAtIndex(args) {
|
|
500
|
+
const { node, parentId, index, newNode } = args;
|
|
501
|
+
if (node.id === parentId) {
|
|
502
|
+
const children = node.children ?? [];
|
|
503
|
+
if (index < 0 || index > children.length) {
|
|
504
|
+
return { node, inserted: false };
|
|
505
|
+
}
|
|
506
|
+
const nextChildren = [...children.slice(0, index), newNode, ...children.slice(index)];
|
|
507
|
+
return {
|
|
508
|
+
node: {
|
|
509
|
+
...node,
|
|
510
|
+
children: nextChildren,
|
|
511
|
+
},
|
|
512
|
+
inserted: true,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
if (!node.children || node.children.length === 0) {
|
|
516
|
+
return { node, inserted: false };
|
|
517
|
+
}
|
|
518
|
+
const results = node.children.map((child) => insertChildAtIndex({ node: child, parentId, index, newNode }));
|
|
519
|
+
const inserted = results.some((result) => result.inserted);
|
|
520
|
+
if (!inserted) {
|
|
521
|
+
return { node, inserted: false };
|
|
522
|
+
}
|
|
523
|
+
return {
|
|
524
|
+
node: {
|
|
525
|
+
...node,
|
|
526
|
+
children: results.map((result) => result.node),
|
|
527
|
+
},
|
|
528
|
+
inserted: true,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
export function insertNodeAtPlacement(args) {
|
|
532
|
+
const { root, placement, componentMeta, makeNode } = args;
|
|
533
|
+
const newNode = makeNode();
|
|
534
|
+
const validation = validateNodePlacement({
|
|
535
|
+
root,
|
|
536
|
+
placement,
|
|
537
|
+
childType: newNode.type,
|
|
538
|
+
componentMeta,
|
|
539
|
+
});
|
|
540
|
+
if (!validation.ok) {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
const result = insertChildAtIndex({
|
|
544
|
+
node: root,
|
|
545
|
+
parentId: placement.parentId,
|
|
546
|
+
index: placement.index,
|
|
547
|
+
newNode,
|
|
548
|
+
});
|
|
549
|
+
if (!result.inserted)
|
|
550
|
+
return null;
|
|
551
|
+
return { root: result.node, insertedNodeId: newNode.id };
|
|
552
|
+
}
|
|
553
|
+
function getAdjustedMovePlacement(args) {
|
|
554
|
+
const { source, placement } = args;
|
|
555
|
+
if (!source.parent) {
|
|
556
|
+
return null;
|
|
557
|
+
}
|
|
558
|
+
if (placement.referenceId === source.node.id) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const sourceParentId = source.parent.id;
|
|
562
|
+
if (placement.parentId !== sourceParentId) {
|
|
563
|
+
return placement;
|
|
564
|
+
}
|
|
565
|
+
const adjustedIndex = source.index < placement.index ? placement.index - 1 : placement.index;
|
|
566
|
+
if (adjustedIndex === source.index) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
...placement,
|
|
571
|
+
index: adjustedIndex,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
export function moveNodeToPlacement(args) {
|
|
575
|
+
const { root, nodeId, placement, componentMeta } = args;
|
|
576
|
+
const source = findNodeWithParent(root, nodeId);
|
|
577
|
+
if (!source) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
if (!source.parent) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
if (placement.parentId === nodeId) {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
if (isDescendantNode(source.node, placement.parentId)) {
|
|
587
|
+
return null;
|
|
588
|
+
}
|
|
589
|
+
const adjustedPlacement = getAdjustedMovePlacement({ source, placement });
|
|
590
|
+
if (!adjustedPlacement) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const removed = removeNodeForMove({ node: root, nodeId });
|
|
594
|
+
if (!removed.removedNode) {
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
const validation = validateNodePlacement({
|
|
598
|
+
root: removed.node,
|
|
599
|
+
placement: adjustedPlacement,
|
|
600
|
+
childType: removed.removedNode.type,
|
|
601
|
+
componentMeta,
|
|
602
|
+
});
|
|
603
|
+
if (!validation.ok) {
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
const inserted = insertChildAtIndex({
|
|
607
|
+
node: removed.node,
|
|
608
|
+
parentId: adjustedPlacement.parentId,
|
|
609
|
+
index: adjustedPlacement.index,
|
|
610
|
+
newNode: removed.removedNode,
|
|
611
|
+
});
|
|
612
|
+
if (!inserted.inserted) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
root: inserted.node,
|
|
617
|
+
movedNodeId: removed.removedNode.id,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const CATEGORY_LABELS = {
|
|
621
|
+
layout: 'Layouts',
|
|
622
|
+
pattern: 'Patterns',
|
|
623
|
+
component: 'Components',
|
|
624
|
+
foundation: 'Foundation',
|
|
625
|
+
recipe: 'Recipes',
|
|
626
|
+
};
|
|
627
|
+
const CATEGORY_ORDER = ['layout', 'pattern', 'component', 'foundation', 'recipe'];
|
|
628
|
+
export const STUDIO_INSERT_RECIPES = [
|
|
629
|
+
{
|
|
630
|
+
id: 'screen-section',
|
|
631
|
+
label: 'Screen section',
|
|
632
|
+
description: 'A screen section with a starter heading.',
|
|
633
|
+
category: 'recipe',
|
|
634
|
+
root: {
|
|
635
|
+
type: 'ScreenSection',
|
|
636
|
+
children: [{ type: 'Heading' }],
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: 'panel-stack',
|
|
641
|
+
label: 'Panel stack',
|
|
642
|
+
description: 'Panel with a stack starter.',
|
|
643
|
+
category: 'recipe',
|
|
644
|
+
root: {
|
|
645
|
+
type: 'Panel',
|
|
646
|
+
children: [{ type: 'Stack' }],
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
{
|
|
650
|
+
id: 'card-heading',
|
|
651
|
+
label: 'Card heading',
|
|
652
|
+
description: 'Card with a headline.',
|
|
653
|
+
category: 'recipe',
|
|
654
|
+
root: {
|
|
655
|
+
type: 'Card',
|
|
656
|
+
children: [{ type: 'Heading' }],
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
];
|
|
660
|
+
export function getInsertCatalogCategoryLabel(category) {
|
|
661
|
+
return CATEGORY_LABELS[category] ?? category;
|
|
662
|
+
}
|
|
663
|
+
function resolveCategoryOrder(category) {
|
|
664
|
+
const index = CATEGORY_ORDER.indexOf(category);
|
|
665
|
+
return index === -1 ? CATEGORY_ORDER.length : index;
|
|
666
|
+
}
|
|
667
|
+
function describeInsertRecipeIssue(issue) {
|
|
668
|
+
if (issue.code === 'missing-meta') {
|
|
669
|
+
return `Missing component metadata for ${issue.nodeType}.`;
|
|
670
|
+
}
|
|
671
|
+
return `Child ${issue.childType ?? 'unknown'} is not allowed under ${issue.nodeType}.`;
|
|
672
|
+
}
|
|
673
|
+
export function validateInsertRecipe(recipe, componentMeta) {
|
|
674
|
+
const visit = (node, path) => {
|
|
675
|
+
const meta = componentMeta[node.type];
|
|
676
|
+
if (!meta) {
|
|
677
|
+
return {
|
|
678
|
+
code: 'missing-meta',
|
|
679
|
+
path,
|
|
680
|
+
nodeType: node.type,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
const children = node.children ?? [];
|
|
684
|
+
for (const child of children) {
|
|
685
|
+
if (!meta.allowedChildren.includes(child.type)) {
|
|
686
|
+
return {
|
|
687
|
+
code: 'child-not-allowed',
|
|
688
|
+
path,
|
|
689
|
+
nodeType: node.type,
|
|
690
|
+
childType: child.type,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
const nested = visit(child, [...path, child.type]);
|
|
694
|
+
if (nested) {
|
|
695
|
+
return nested;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return null;
|
|
699
|
+
};
|
|
700
|
+
return visit(recipe.root, [recipe.root.type]);
|
|
701
|
+
}
|
|
702
|
+
function createNodeFromRecipe(recipe, componentMeta, createId) {
|
|
703
|
+
const buildNode = (node) => {
|
|
704
|
+
const meta = componentMeta[node.type];
|
|
705
|
+
const defaultProps = meta?.blueprint?.defaultProps;
|
|
706
|
+
return {
|
|
707
|
+
id: createId(node.type),
|
|
708
|
+
type: node.type,
|
|
709
|
+
props: defaultProps ? { ...defaultProps } : {},
|
|
710
|
+
children: (node.children ?? []).map(buildNode),
|
|
711
|
+
};
|
|
712
|
+
};
|
|
713
|
+
return buildNode(recipe.root);
|
|
714
|
+
}
|
|
715
|
+
export function createNodeFromCatalogEntry(entry, componentMeta, createId = generateStudioId) {
|
|
716
|
+
if (entry.kind === 'recipe') {
|
|
717
|
+
return createNodeFromRecipe(entry.recipe, componentMeta, createId);
|
|
718
|
+
}
|
|
719
|
+
const meta = componentMeta[entry.componentType];
|
|
720
|
+
const defaultProps = meta?.blueprint?.defaultProps;
|
|
721
|
+
return {
|
|
722
|
+
id: createId(entry.componentType),
|
|
723
|
+
type: entry.componentType,
|
|
724
|
+
props: defaultProps ? { ...defaultProps } : {},
|
|
725
|
+
children: [],
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function createComponentEntry(componentType, meta) {
|
|
729
|
+
const isDirect = meta.directManifestNode === true;
|
|
730
|
+
return {
|
|
731
|
+
id: `component:${componentType}`,
|
|
732
|
+
label: meta.blueprint?.label ?? componentType,
|
|
733
|
+
category: meta.category,
|
|
734
|
+
rootType: componentType,
|
|
735
|
+
kind: 'component',
|
|
736
|
+
componentType,
|
|
737
|
+
status: isDirect ? 'enabled' : 'disabled',
|
|
738
|
+
disabledReason: isDirect
|
|
739
|
+
? undefined
|
|
740
|
+
: {
|
|
741
|
+
code: 'not-direct',
|
|
742
|
+
detail: 'Not a direct manifest node.',
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function createRecipeEntry(recipe, componentMeta) {
|
|
747
|
+
const issue = validateInsertRecipe(recipe, componentMeta);
|
|
748
|
+
if (issue) {
|
|
749
|
+
const code = issue.code === 'missing-meta' ? 'missing-meta' : 'invalid-recipe';
|
|
750
|
+
return {
|
|
751
|
+
id: `recipe:${recipe.id}`,
|
|
752
|
+
label: recipe.label,
|
|
753
|
+
description: recipe.description,
|
|
754
|
+
category: 'recipe',
|
|
755
|
+
rootType: recipe.root.type,
|
|
756
|
+
kind: 'recipe',
|
|
757
|
+
recipe,
|
|
758
|
+
status: 'disabled',
|
|
759
|
+
disabledReason: {
|
|
760
|
+
code,
|
|
761
|
+
detail: describeInsertRecipeIssue(issue),
|
|
762
|
+
issue,
|
|
763
|
+
},
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
id: `recipe:${recipe.id}`,
|
|
768
|
+
label: recipe.label,
|
|
769
|
+
description: recipe.description,
|
|
770
|
+
category: 'recipe',
|
|
771
|
+
rootType: recipe.root.type,
|
|
772
|
+
kind: 'recipe',
|
|
773
|
+
recipe,
|
|
774
|
+
status: 'enabled',
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
export function buildInsertCatalogEntries(args) {
|
|
778
|
+
const { componentMeta, recipes = STUDIO_INSERT_RECIPES } = args;
|
|
779
|
+
const componentEntries = [];
|
|
780
|
+
for (const [type, meta] of Object.entries(componentMeta)) {
|
|
781
|
+
if (meta) {
|
|
782
|
+
componentEntries.push(createComponentEntry(type, meta));
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
const recipeEntries = recipes.map((recipe) => createRecipeEntry(recipe, componentMeta));
|
|
786
|
+
return [...componentEntries, ...recipeEntries].sort((left, right) => {
|
|
787
|
+
const leftOrder = resolveCategoryOrder(left.category);
|
|
788
|
+
const rightOrder = resolveCategoryOrder(right.category);
|
|
789
|
+
if (leftOrder !== rightOrder) {
|
|
790
|
+
return leftOrder - rightOrder;
|
|
791
|
+
}
|
|
792
|
+
return left.label.localeCompare(right.label);
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
function resolvePlacementForEntry(args) {
|
|
796
|
+
const { entry, root, selectedNodeId, componentMeta } = args;
|
|
797
|
+
return resolveDefaultInsertPlacement({
|
|
798
|
+
root,
|
|
799
|
+
selectedNodeId,
|
|
800
|
+
childType: entry.rootType,
|
|
801
|
+
componentMeta,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
export function resolveInsertCatalogEntries(args) {
|
|
805
|
+
const { entries, root, selectedNodeId, componentMeta } = args;
|
|
806
|
+
return entries.map((entry) => {
|
|
807
|
+
if (entry.status === 'disabled') {
|
|
808
|
+
return entry;
|
|
809
|
+
}
|
|
810
|
+
if (!root) {
|
|
811
|
+
return {
|
|
812
|
+
...entry,
|
|
813
|
+
status: 'disabled',
|
|
814
|
+
disabledReason: {
|
|
815
|
+
code: 'no-placement',
|
|
816
|
+
detail: 'No active screen available.',
|
|
817
|
+
},
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
const placement = resolvePlacementForEntry({
|
|
821
|
+
entry,
|
|
822
|
+
root,
|
|
823
|
+
selectedNodeId,
|
|
824
|
+
componentMeta,
|
|
825
|
+
});
|
|
826
|
+
if (!placement.ok) {
|
|
827
|
+
return {
|
|
828
|
+
...entry,
|
|
829
|
+
status: 'disabled',
|
|
830
|
+
disabledReason: {
|
|
831
|
+
code: 'no-placement',
|
|
832
|
+
detail: placement.reason.message,
|
|
833
|
+
},
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
return {
|
|
837
|
+
...entry,
|
|
838
|
+
status: 'enabled',
|
|
839
|
+
placement: placement.placement,
|
|
840
|
+
};
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
function isStyleRecord(value) {
|
|
844
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
845
|
+
return false;
|
|
846
|
+
return Object.values(value).every((entry) => typeof entry === 'string' || typeof entry === 'number');
|
|
847
|
+
}
|
|
38
848
|
//# sourceMappingURL=index.js.map
|