@jxsuite/studio 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/src/state.js ADDED
@@ -0,0 +1,744 @@
1
+ /**
2
+ * State.js — Builder state model and mutation API
3
+ *
4
+ * All state changes go through named mutation functions. State is immutable — every mutation
5
+ * produces a new state object. History is a linear stack of { document, selection } snapshots.
6
+ *
7
+ * Path convention: [] = root document ['children', 0] = first child ['children', 0, 'children', 2]
8
+ * = third child of first child
9
+ */
10
+
11
+ /**
12
+ * @typedef {Record<string, any>} JxNode
13
+ *
14
+ * @typedef {(string | number)[]} JxPath
15
+ *
16
+ * @typedef {{ document: JxNode; selection: JxPath | null }} HistorySnapshot
17
+ *
18
+ * @typedef {{
19
+ * document: JxNode;
20
+ * selection: JxPath | null;
21
+ * hover: JxPath | null;
22
+ * history: HistorySnapshot[];
23
+ * historyIndex: number;
24
+ * dirty: boolean;
25
+ * fileHandle: any;
26
+ * documentPath: string | null;
27
+ * documentStack: any[];
28
+ * handlersSource: string | null;
29
+ * mode: string;
30
+ * content: { frontmatter: Record<string, any> };
31
+ * ui: Record<string, any>;
32
+ * }} StudioState
33
+ */
34
+
35
+ const HISTORY_LIMIT = 100;
36
+
37
+ // ─── Path utilities ───────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * Walk the document tree and return the node at the given path.
41
+ *
42
+ * @param {any} doc
43
+ * @param {JxPath} path
44
+ * @returns {any}
45
+ */
46
+ export function getNodeAtPath(doc, path) {
47
+ let node = doc;
48
+ for (const key of path) {
49
+ if (node == null) return undefined;
50
+ node = node[key];
51
+ }
52
+ return node;
53
+ }
54
+
55
+ /**
56
+ * Return the path to the parent element (strips trailing 'children' + index).
57
+ *
58
+ * @param {JxPath} path
59
+ * @returns {JxPath | null}
60
+ */
61
+ export function parentElementPath(path) {
62
+ return path.length >= 2 ? path.slice(0, -2) : null;
63
+ }
64
+
65
+ /**
66
+ * Return the child index (last segment of the path).
67
+ *
68
+ * @param {JxPath} path
69
+ * @returns {string | number}
70
+ */
71
+ export function childIndex(path) {
72
+ return path[path.length - 1];
73
+ }
74
+
75
+ /**
76
+ * Serialize a path to a string key for Map lookups.
77
+ *
78
+ * @param {JxPath} path
79
+ * @returns {string}
80
+ */
81
+ export function pathKey(path) {
82
+ return path.join("/");
83
+ }
84
+
85
+ /**
86
+ * Compare two paths for equality.
87
+ *
88
+ * @param {JxPath | null} a
89
+ * @param {JxPath | null} b
90
+ * @returns {boolean}
91
+ */
92
+ export function pathsEqual(a, b) {
93
+ if (a === b) return true;
94
+ if (!a || !b || a.length !== b.length) return false;
95
+ return a.every((v, i) => v === b[i]);
96
+ }
97
+
98
+ /**
99
+ * Returns true if `path` is an ancestor of (or equal to) `descendant`.
100
+ *
101
+ * @param {JxPath} path
102
+ * @param {JxPath} descendant
103
+ * @returns {boolean}
104
+ */
105
+ export function isAncestor(path, descendant) {
106
+ if (path.length > descendant.length) return false;
107
+ return path.every((v, i) => v === descendant[i]);
108
+ }
109
+
110
+ // ─── Tree flattening (for layer panel) ────────────────────────────────────────
111
+
112
+ /**
113
+ * Flatten a Jx document into an array of { node, path, depth, nodeType } rows. Walks static
114
+ * children arrays, $map templates, and $switch cases.
115
+ *
116
+ * NodeType: 'element' (default) | 'map' | 'case' | 'case-ref'
117
+ *
118
+ * @param {any} doc
119
+ * @param {JxPath} [path]
120
+ * @param {number} [depth]
121
+ * @returns {{ node: any; path: JxPath; depth: number; nodeType: string }[]}
122
+ */
123
+ export function flattenTree(doc, path = [], depth = 0) {
124
+ // Text node children: bare primitives get a "text" row
125
+ if (typeof doc === "string" || typeof doc === "number" || typeof doc === "boolean") {
126
+ return [{ node: doc, path, depth, nodeType: "text" }];
127
+ }
128
+
129
+ /** @type {{ node: any; path: JxPath; depth: number; nodeType: string }[]} */
130
+ const rows = [{ node: doc, path, depth, nodeType: "element" }];
131
+
132
+ // Custom component instances are atomic in the layer tree — don't recurse into internals
133
+ if (doc.$props && (doc.tagName || "").includes("-")) {
134
+ return rows;
135
+ }
136
+
137
+ const children = doc.children;
138
+
139
+ if (Array.isArray(children)) {
140
+ for (let i = 0; i < children.length; i++) {
141
+ const childPath = [...path, "children", i];
142
+ rows.push(...flattenTree(children[i], childPath, depth + 1));
143
+ }
144
+ } else if (children && typeof children === "object" && children.$prototype === "Array") {
145
+ // $map — emit the map container, then recurse into the template
146
+ rows.push({ node: children, path: [...path, "children"], depth: depth + 1, nodeType: "map" });
147
+ const mapDef = children.map;
148
+ if (mapDef && typeof mapDef === "object") {
149
+ rows.push(...flattenTree(mapDef, [...path, "children", "map"], depth + 2));
150
+ }
151
+ }
152
+
153
+ // $switch — emit each case as a virtual child
154
+ if (doc.$switch && doc.cases && typeof doc.cases === "object") {
155
+ for (const [caseName, caseDef] of Object.entries(doc.cases)) {
156
+ const casePath = [...path, "cases", caseName];
157
+ if (caseDef && typeof caseDef === "object" && /** @type {any} */ (caseDef).$ref) {
158
+ rows.push({ node: caseDef, path: casePath, depth: depth + 1, nodeType: "case-ref" });
159
+ } else if (caseDef && typeof caseDef === "object") {
160
+ rows.push({ node: caseDef, path: casePath, depth: depth + 1, nodeType: "case" });
161
+ // Recurse into case children (skip the case node itself — already emitted)
162
+ const caseChildren = flattenTree(caseDef, casePath, depth + 2);
163
+ rows.push(...caseChildren.slice(1));
164
+ }
165
+ }
166
+ }
167
+
168
+ return rows;
169
+ }
170
+
171
+ /**
172
+ * Get a display label for a node (for layers + overlays).
173
+ *
174
+ * @param {any} node
175
+ * @returns {string}
176
+ */
177
+ export function nodeLabel(node) {
178
+ if (!node) return "?";
179
+ // $map container (Repeater)
180
+ if (node.$prototype === "Array") {
181
+ const ref = node.items?.$ref || "items";
182
+ return `Repeater → ${ref}`;
183
+ }
184
+ if (node.$id) return node.$id;
185
+ const tag = node.tagName ?? "div";
186
+ const suffix = node.$switch ? " ⇆" : "";
187
+ if (typeof node.textContent === "string" && node.textContent.length > 0) {
188
+ return `${tag} — ${node.textContent.slice(0, 24)}${suffix}`;
189
+ }
190
+ return tag + suffix;
191
+ }
192
+
193
+ // ─── State factory ────────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * @param {any} doc
197
+ * @returns {StudioState}
198
+ */
199
+ export function createState(doc) {
200
+ const initial = { document: doc, selection: null };
201
+ return {
202
+ document: doc,
203
+ selection: null,
204
+ hover: null,
205
+ history: [initial],
206
+ historyIndex: 0,
207
+ dirty: false,
208
+ fileHandle: null,
209
+ documentPath: null, // root-relative path, e.g. "examples/markdown/blog.json"
210
+ documentStack: [], // frames for component navigation
211
+ handlersSource: null,
212
+ mode: "component", // 'component' | 'content'
213
+ content: { frontmatter: {} }, // frontmatter metadata for .md files
214
+ ui: {
215
+ leftTab: "layers", // 'files' | 'layers' | 'blocks' | 'state' | 'data'
216
+ rightTab: "properties", // 'properties' | 'events' | 'style'
217
+ zoom: 1,
218
+ activeMedia: null, // '--md' | null (base) — focused canvas/breakpoint
219
+ activeSelector: null, // ':hover' | '.child' | null (base) — nested selector context
220
+ featureToggles: {}, // { '--dark': true } — non-size media toggles
221
+ styleSections: {}, // { layout: true, ... } — section open/closed state
222
+ inspectorSections: {}, // { identity: true, ... } — properties panel section open/closed state
223
+ styleShorthands: {}, // { padding: true, ... } — shorthand expand/collapse state
224
+ editingFunction: null, // null | { type: 'def', defName } | { type: 'event', path, eventKey }
225
+ stylebookSelection: null, // tag name string, e.g. "h1"
226
+ stylebookTab: "elements", // "elements" | "variables"
227
+ stylebookFilter: "", // search filter text
228
+ stylebookCustomizedOnly: false, // show only customized elements
229
+ },
230
+ };
231
+ }
232
+
233
+ // ─── Project state (persists across document switches) ────────────────────────
234
+ //
235
+ // Shape: { root, name, projectRoot, isSiteProject, projectConfig,
236
+ // dirs: Map<string, DirEntry[]>, expanded: Set<string>,
237
+ // selectedPath: string|null, searchQuery: string }
238
+ // DirEntry: { name, path, type: "file"|"directory", size, modified }
239
+
240
+ /** @type {any} */
241
+ export let projectState = null;
242
+
243
+ /** @param {any} ps */
244
+ export function setProjectState(ps) {
245
+ projectState = ps;
246
+ }
247
+
248
+ // ─── Core mutation ────────────────────────────────────────────────────────────
249
+
250
+ /**
251
+ * Apply a mutation to the document. Clones the document immutably, applies the mutation function to
252
+ * the clone, and pushes to history.
253
+ *
254
+ * @param {StudioState} state
255
+ * @param {(doc: any) => void} mutationFn
256
+ * @returns {StudioState}
257
+ */
258
+ export function applyMutation(state, mutationFn) {
259
+ const newDoc = structuredClone(state.document);
260
+ mutationFn(newDoc);
261
+ const truncated = state.history.slice(0, state.historyIndex + 1);
262
+ truncated.push({ document: newDoc, selection: state.selection });
263
+ if (truncated.length > HISTORY_LIMIT) truncated.shift();
264
+ return {
265
+ ...state,
266
+ document: newDoc,
267
+ history: truncated,
268
+ historyIndex: truncated.length - 1,
269
+ dirty: true,
270
+ };
271
+ }
272
+
273
+ // ─── Selection / hover ────────────────────────────────────────────────────────
274
+
275
+ /**
276
+ * @param {StudioState} state
277
+ * @param {JxPath | null} path
278
+ * @returns {StudioState}
279
+ */
280
+ export function selectNode(state, path) {
281
+ return { ...state, selection: path };
282
+ }
283
+
284
+ /**
285
+ * @param {StudioState} state
286
+ * @param {JxPath | null} path
287
+ * @returns {StudioState}
288
+ */
289
+ export function hoverNode(state, path) {
290
+ return { ...state, hover: path };
291
+ }
292
+
293
+ // ─── Undo / redo ──────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * @param {StudioState} state
297
+ * @returns {StudioState}
298
+ */
299
+ export function undo(state) {
300
+ if (state.historyIndex <= 0) return state;
301
+ const idx = state.historyIndex - 1;
302
+ const snap = state.history[idx];
303
+ return {
304
+ ...state,
305
+ document: snap.document,
306
+ selection: snap.selection,
307
+ historyIndex: idx,
308
+ dirty: true,
309
+ };
310
+ }
311
+
312
+ /**
313
+ * @param {StudioState} state
314
+ * @returns {StudioState}
315
+ */
316
+ export function redo(state) {
317
+ if (state.historyIndex >= state.history.length - 1) return state;
318
+ const idx = state.historyIndex + 1;
319
+ const snap = state.history[idx];
320
+ return {
321
+ ...state,
322
+ document: snap.document,
323
+ selection: snap.selection,
324
+ historyIndex: idx,
325
+ dirty: true,
326
+ };
327
+ }
328
+
329
+ // ─── Document mutations ───────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * @param {StudioState} state
333
+ * @param {JxPath} parentPath
334
+ * @param {number} index
335
+ * @param {any} nodeDef
336
+ * @returns {StudioState}
337
+ */
338
+ export function insertNode(state, parentPath, index, nodeDef) {
339
+ return applyMutation(state, (doc) => {
340
+ const parent = getNodeAtPath(doc, parentPath);
341
+ if (!parent.children) parent.children = [];
342
+ parent.children.splice(index, 0, nodeDef);
343
+ });
344
+ }
345
+
346
+ /**
347
+ * @param {StudioState} state
348
+ * @param {JxPath} path
349
+ * @returns {StudioState}
350
+ */
351
+ export function removeNode(state, path) {
352
+ if (!path || path.length < 2) return state; // can't remove root
353
+ const elemPath = parentElementPath(path);
354
+ const idx = childIndex(path);
355
+ const newState = applyMutation(state, (doc) => {
356
+ getNodeAtPath(doc, /** @type {JxPath} */ (elemPath)).children.splice(idx, 1);
357
+ });
358
+ // Clear selection if we removed the selected node
359
+ if (state.selection && isAncestor(path, state.selection)) {
360
+ return { ...newState, selection: null };
361
+ }
362
+ return newState;
363
+ }
364
+
365
+ /**
366
+ * @param {StudioState} state
367
+ * @param {JxPath} path
368
+ * @returns {StudioState}
369
+ */
370
+ export function duplicateNode(state, path) {
371
+ if (!path || path.length < 2) return state;
372
+ const node = getNodeAtPath(state.document, path);
373
+ if (!node) return state;
374
+ const elemPath = /** @type {JxPath} */ (parentElementPath(path));
375
+ const idx = /** @type {number} */ (childIndex(path));
376
+ const newState = insertNode(state, elemPath, idx + 1, structuredClone(node));
377
+ return selectNode(newState, [...elemPath, "children", idx + 1]);
378
+ }
379
+
380
+ /**
381
+ * @param {StudioState} state
382
+ * @param {JxPath} fromPath
383
+ * @param {JxPath} toParentPath
384
+ * @param {number} toIndex
385
+ * @returns {StudioState}
386
+ */
387
+ export function moveNode(state, fromPath, toParentPath, toIndex) {
388
+ const newState = applyMutation(state, (doc) => {
389
+ const fromParentPath = /** @type {JxPath} */ (parentElementPath(fromPath));
390
+ const fromParent = getNodeAtPath(doc, fromParentPath);
391
+ const fromIdx = childIndex(fromPath);
392
+ const [node] = fromParent.children.splice(fromIdx, 1);
393
+ const toParent = getNodeAtPath(doc, toParentPath);
394
+ if (!toParent.children) toParent.children = [];
395
+ // Adjust target index if moving within the same parent and source was before target
396
+ let adjustedIndex = toIndex;
397
+ if (fromParent === toParent && /** @type {number} */ (fromIdx) < toIndex) {
398
+ adjustedIndex--;
399
+ }
400
+ toParent.children.splice(adjustedIndex, 0, node);
401
+ });
402
+ // Update selection to follow the moved node
403
+ if (pathsEqual(newState.selection, fromPath)) {
404
+ let adjustedIdx = toIndex;
405
+ // Adjust if same parent and source was before target
406
+ const fromParentPath = /** @type {JxPath} */ (parentElementPath(fromPath));
407
+ const fromIdx = childIndex(fromPath);
408
+ if (
409
+ fromParentPath.length === toParentPath.length &&
410
+ fromParentPath.every((v, i) => v === toParentPath[i]) &&
411
+ /** @type {number} */ (fromIdx) < toIndex
412
+ ) {
413
+ adjustedIdx = toIndex - 1;
414
+ }
415
+ newState.selection = [...toParentPath, "children", adjustedIdx];
416
+ }
417
+ return newState;
418
+ }
419
+
420
+ /**
421
+ * @param {StudioState} state
422
+ * @param {JxPath} path
423
+ * @param {string} key
424
+ * @param {any} value
425
+ * @returns {StudioState}
426
+ */
427
+ export function updateProperty(state, path, key, value) {
428
+ return applyMutation(state, (doc) => {
429
+ const node = getNodeAtPath(doc, path);
430
+ if (value === undefined || value === null || value === "") delete node[key];
431
+ else node[key] = value;
432
+ });
433
+ }
434
+
435
+ /**
436
+ * @param {StudioState} state
437
+ * @param {JxPath} path
438
+ * @param {string} prop
439
+ * @param {any} value
440
+ * @returns {StudioState}
441
+ */
442
+ export function updateStyle(state, path, prop, value) {
443
+ return applyMutation(state, (doc) => {
444
+ const node = getNodeAtPath(doc, path);
445
+ if (!node.style) node.style = {};
446
+ if (value === undefined || value === "") delete node.style[prop];
447
+ else node.style[prop] = value;
448
+ if (Object.keys(node.style).length === 0) delete node.style;
449
+ });
450
+ }
451
+
452
+ /**
453
+ * @param {StudioState} state
454
+ * @param {JxPath} path
455
+ * @param {string} attr
456
+ * @param {any} value
457
+ * @returns {StudioState}
458
+ */
459
+ export function updateAttribute(state, path, attr, value) {
460
+ return applyMutation(state, (doc) => {
461
+ const node = getNodeAtPath(doc, path);
462
+ if (!node.attributes) node.attributes = {};
463
+ if (value === undefined || value === "") delete node.attributes[attr];
464
+ else node.attributes[attr] = value;
465
+ if (Object.keys(node.attributes).length === 0) delete node.attributes;
466
+ });
467
+ }
468
+
469
+ /**
470
+ * @param {StudioState} state
471
+ * @param {string} name
472
+ * @param {any} def
473
+ * @returns {StudioState}
474
+ */
475
+ export function addDef(state, name, def) {
476
+ return applyMutation(state, (doc) => {
477
+ if (!doc.state) doc.state = {};
478
+ doc.state[name] = def;
479
+ });
480
+ }
481
+
482
+ /**
483
+ * @param {StudioState} state
484
+ * @param {string} name
485
+ * @returns {StudioState}
486
+ */
487
+ export function removeDef(state, name) {
488
+ return applyMutation(state, (doc) => {
489
+ if (doc.state) {
490
+ delete doc.state[name];
491
+ if (Object.keys(doc.state).length === 0) delete doc.state;
492
+ }
493
+ });
494
+ }
495
+
496
+ /**
497
+ * @param {StudioState} state
498
+ * @param {string} name
499
+ * @param {Record<string, any>} updates
500
+ * @returns {StudioState}
501
+ */
502
+ export function updateDef(state, name, updates) {
503
+ return applyMutation(state, (doc) => {
504
+ if (!doc.state) doc.state = {};
505
+ if (!doc.state[name]) doc.state[name] = {};
506
+ Object.assign(doc.state[name], updates);
507
+ for (const k of Object.keys(doc.state[name])) {
508
+ if (doc.state[name][k] === undefined || doc.state[name][k] === null) {
509
+ delete doc.state[name][k];
510
+ }
511
+ }
512
+ });
513
+ }
514
+
515
+ /**
516
+ * @param {StudioState} state
517
+ * @param {string} oldName
518
+ * @param {string} newName
519
+ * @returns {StudioState}
520
+ */
521
+ export function renameDef(state, oldName, newName) {
522
+ return applyMutation(state, (doc) => {
523
+ if (!doc.state || !doc.state[oldName]) return;
524
+ doc.state[newName] = doc.state[oldName];
525
+ delete doc.state[oldName];
526
+ });
527
+ }
528
+
529
+ // ─── Media mutations ─────────────────────────────────────────────────────────
530
+
531
+ /**
532
+ * Update a style property inside a media override block (e.g., `@--md`).
533
+ *
534
+ * @param {StudioState} state
535
+ * @param {JxPath} path
536
+ * @param {string} mediaName
537
+ * @param {string} prop
538
+ * @param {any} value
539
+ * @returns {StudioState}
540
+ */
541
+ export function updateMediaStyle(state, path, mediaName, prop, value) {
542
+ return applyMutation(state, (doc) => {
543
+ const node = getNodeAtPath(doc, path);
544
+ if (!node.style) node.style = {};
545
+ const key = `@${mediaName}`;
546
+ if (!node.style[key]) node.style[key] = {};
547
+ if (value === undefined || value === "") {
548
+ delete node.style[key][prop];
549
+ if (Object.keys(node.style[key]).length === 0) delete node.style[key];
550
+ } else {
551
+ node.style[key][prop] = value;
552
+ }
553
+ if (Object.keys(node.style).length === 0) delete node.style;
554
+ });
555
+ }
556
+
557
+ /**
558
+ * Update a style property inside a nested selector block (e.g., :hover).
559
+ *
560
+ * @param {StudioState} state
561
+ * @param {JxPath} path
562
+ * @param {string} selector
563
+ * @param {string} prop
564
+ * @param {any} value
565
+ * @returns {StudioState}
566
+ */
567
+ export function updateNestedStyle(state, path, selector, prop, value) {
568
+ return applyMutation(state, (doc) => {
569
+ const node = getNodeAtPath(doc, path);
570
+ if (!node.style) node.style = {};
571
+ if (!node.style[selector]) node.style[selector] = {};
572
+ if (value === undefined || value === "") {
573
+ delete node.style[selector][prop];
574
+ if (Object.keys(node.style[selector]).length === 0) delete node.style[selector];
575
+ } else {
576
+ node.style[selector][prop] = value;
577
+ }
578
+ if (Object.keys(node.style).length === 0) delete node.style;
579
+ });
580
+ }
581
+
582
+ /**
583
+ * Update a style property inside a nested selector within a media block (e.g., `@--md` > `:hover`).
584
+ *
585
+ * @param {StudioState} state
586
+ * @param {JxPath} path
587
+ * @param {string} mediaName
588
+ * @param {string} selector
589
+ * @param {string} prop
590
+ * @param {any} value
591
+ * @returns {StudioState}
592
+ */
593
+ export function updateMediaNestedStyle(state, path, mediaName, selector, prop, value) {
594
+ return applyMutation(state, (doc) => {
595
+ const node = getNodeAtPath(doc, path);
596
+ if (!node.style) node.style = {};
597
+ const key = `@${mediaName}`;
598
+ if (!node.style[key]) node.style[key] = {};
599
+ if (!node.style[key][selector]) node.style[key][selector] = {};
600
+ if (value === undefined || value === "") {
601
+ delete node.style[key][selector][prop];
602
+ if (Object.keys(node.style[key][selector]).length === 0) delete node.style[key][selector];
603
+ if (Object.keys(node.style[key]).length === 0) delete node.style[key];
604
+ } else {
605
+ node.style[key][selector][prop] = value;
606
+ }
607
+ if (Object.keys(node.style).length === 0) delete node.style;
608
+ });
609
+ }
610
+
611
+ /**
612
+ * Add or update a named media entry at the document root.
613
+ *
614
+ * @param {StudioState} state
615
+ * @param {string} name
616
+ * @param {any} query
617
+ * @returns {StudioState}
618
+ */
619
+ export function updateMedia(state, name, query) {
620
+ return applyMutation(state, (doc) => {
621
+ if (!doc.$media) doc.$media = {};
622
+ if (query === undefined || query === "") {
623
+ delete doc.$media[name];
624
+ if (Object.keys(doc.$media).length === 0) delete doc.$media;
625
+ } else {
626
+ doc.$media[name] = query;
627
+ }
628
+ });
629
+ }
630
+
631
+ // ─── Document stack (component navigation) ──────────────────────────────────
632
+
633
+ /**
634
+ * Push current document onto the stack and switch to editing a new document.
635
+ *
636
+ * @param {StudioState} state
637
+ * @param {any} doc
638
+ * @param {string | null} documentPath
639
+ * @returns {StudioState}
640
+ */
641
+ export function pushDocument(state, doc, documentPath) {
642
+ const frame = {
643
+ document: state.document,
644
+ selection: state.selection,
645
+ fileHandle: state.fileHandle,
646
+ documentPath: state.documentPath,
647
+ dirty: state.dirty,
648
+ history: state.history,
649
+ historyIndex: state.historyIndex,
650
+ mode: state.mode,
651
+ };
652
+ const newState = createState(doc);
653
+ newState.documentStack = [...(state.documentStack || []), frame];
654
+ newState.documentPath = documentPath;
655
+ newState.ui = { ...state.ui, leftTab: "layers", activeMedia: null, activeSelector: null };
656
+ return newState;
657
+ }
658
+
659
+ /**
660
+ * Pop the document stack and return to the previous document.
661
+ *
662
+ * @param {StudioState} state
663
+ * @returns {StudioState}
664
+ */
665
+ export function popDocument(state) {
666
+ if (!state.documentStack || state.documentStack.length === 0) return state;
667
+ const stack = [...state.documentStack];
668
+ const frame = stack.pop();
669
+ return {
670
+ ...state,
671
+ ...frame,
672
+ documentStack: stack,
673
+ ui: { ...state.ui, leftTab: "layers" },
674
+ };
675
+ }
676
+
677
+ // ─── $props mutations ────────────────────────────────────────────────────────
678
+
679
+ /**
680
+ * Update a $prop on a component instance.
681
+ *
682
+ * @param {StudioState} state
683
+ * @param {JxPath} path
684
+ * @param {string} propName
685
+ * @param {any} value
686
+ * @returns {StudioState}
687
+ */
688
+ export function updateProp(state, path, propName, value) {
689
+ return applyMutation(state, (doc) => {
690
+ const node = getNodeAtPath(doc, path);
691
+ if (!node.$props) node.$props = {};
692
+ if (value === undefined || value === null || value === "") delete node.$props[propName];
693
+ else node.$props[propName] = value;
694
+ if (Object.keys(node.$props).length === 0) delete node.$props;
695
+ });
696
+ }
697
+
698
+ // ─── $switch case mutations ──────────────────────────────────────────────────
699
+
700
+ /**
701
+ * @param {StudioState} state
702
+ * @param {JxPath} path
703
+ * @param {string} caseName
704
+ * @param {any} [caseDef]
705
+ * @returns {StudioState}
706
+ */
707
+ export function addSwitchCase(state, path, caseName, caseDef) {
708
+ return applyMutation(state, (doc) => {
709
+ const node = getNodeAtPath(doc, path);
710
+ if (!node.cases) node.cases = {};
711
+ node.cases[caseName] = caseDef || { tagName: "div", textContent: caseName };
712
+ });
713
+ }
714
+
715
+ /**
716
+ * @param {StudioState} state
717
+ * @param {JxPath} path
718
+ * @param {string} caseName
719
+ * @returns {StudioState}
720
+ */
721
+ export function removeSwitchCase(state, path, caseName) {
722
+ return applyMutation(state, (doc) => {
723
+ const node = getNodeAtPath(doc, path);
724
+ if (node.cases) {
725
+ delete node.cases[caseName];
726
+ }
727
+ });
728
+ }
729
+
730
+ /**
731
+ * @param {StudioState} state
732
+ * @param {JxPath} path
733
+ * @param {string} oldName
734
+ * @param {string} newName
735
+ * @returns {StudioState}
736
+ */
737
+ export function renameSwitchCase(state, path, oldName, newName) {
738
+ return applyMutation(state, (doc) => {
739
+ const node = getNodeAtPath(doc, path);
740
+ if (!node.cases || !node.cases[oldName]) return;
741
+ node.cases[newName] = node.cases[oldName];
742
+ delete node.cases[oldName];
743
+ });
744
+ }