@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/dist/studio.css +3676 -0
- package/dist/studio.js +188743 -0
- package/dist/studio.js.map +1448 -0
- package/package.json +67 -0
- package/src/editor/context-menu.js +144 -0
- package/src/editor/inline-edit.js +597 -0
- package/src/editor/inline-format.js +572 -0
- package/src/editor/shortcuts.js +275 -0
- package/src/editor/slash-menu.js +167 -0
- package/src/files/components.js +40 -0
- package/src/files/file-ops.js +195 -0
- package/src/files/files.js +569 -0
- package/src/markdown/md-allowlist.js +101 -0
- package/src/markdown/md-convert.js +491 -0
- package/src/panels/activity-bar.js +69 -0
- package/src/panels/data-explorer.js +181 -0
- package/src/panels/events-panel.js +235 -0
- package/src/panels/imports-panel.js +427 -0
- package/src/panels/signals-panel.js +1093 -0
- package/src/panels/statusbar.js +56 -0
- package/src/platform.js +31 -0
- package/src/platforms/devserver.js +293 -0
- package/src/services/cem-export.js +130 -0
- package/src/services/code-services.js +98 -0
- package/src/site-context.js +122 -0
- package/src/state.js +744 -0
- package/src/store.js +332 -0
- package/src/studio.js +7692 -0
- package/src/ui/icons.js +83 -0
- package/src/ui/jx-styled-combobox.js +142 -0
- package/src/ui/spectrum.js +238 -0
- package/src/utils/studio-utils.js +185 -0
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
|
+
}
|