@jxsuite/studio 0.1.0 → 0.5.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.js +50941 -34749
- package/dist/studio.js.map +461 -345
- package/package.json +46 -35
- package/src/browse/browse.js +414 -0
- package/src/editor/context-menu.js +48 -1
- package/src/editor/convert-to-component.js +208 -0
- package/src/editor/inline-edit.js +33 -6
- package/src/editor/shortcuts.js +6 -1
- package/src/files/components.js +4 -2
- package/src/files/file-ops.js +102 -54
- package/src/files/files.js +22 -8
- package/src/markdown/md-convert.js +309 -11
- package/src/panels/activity-bar.js +3 -0
- package/src/panels/head-panel.js +576 -0
- package/src/panels/overlays.js +133 -0
- package/src/panels/right-panel.js +130 -0
- package/src/panels/shared.js +41 -0
- package/src/panels/signals-panel.js +95 -94
- package/src/panels/statusbar.js +15 -1
- package/src/panels/toolbar.js +223 -0
- package/src/platforms/devserver.js +58 -16
- package/src/settings/collections-editor.js +428 -0
- package/src/settings/defs-editor.js +418 -0
- package/src/settings/schema-field-ui.js +329 -0
- package/src/state.js +99 -2
- package/src/store.js +112 -41
- package/src/studio.js +1551 -1565
- package/src/ui/button-group.js +91 -0
- package/src/ui/color-selector.js +299 -0
- package/src/ui/field-row.js +47 -0
- package/src/ui/media-picker.js +172 -0
- package/src/ui/panel-resize.js +96 -0
- package/src/ui/spectrum.js +36 -2
- package/src/ui/unit-selector.js +106 -0
- package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
- package/src/ui/widgets.js +106 -0
- package/src/utils/canvas-media.js +151 -0
- package/src/utils/inherited-style.js +54 -0
- package/src/utils/studio-utils.js +32 -0
- package/src/view.js +68 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema field UI — shared field-card and add-field-dialog templates for the collections and
|
|
3
|
+
* definitions editors.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { html, nothing } from "lit-html";
|
|
7
|
+
|
|
8
|
+
export const FIELD_TYPES = ["string", "number", "boolean", "array", "object", "date"];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* type?: string;
|
|
13
|
+
* properties?: Record<string, SchemaProperty>;
|
|
14
|
+
* required?: string[];
|
|
15
|
+
* items?: any;
|
|
16
|
+
* format?: string;
|
|
17
|
+
* }} SchemaProperty
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* onDelete: (name: string) => void;
|
|
23
|
+
* onToggleRequired: (name: string) => void;
|
|
24
|
+
* onRename: (oldName: string, newName: string) => void;
|
|
25
|
+
* onChangeType: (name: string, newType: string) => void;
|
|
26
|
+
* onAddNestedField?: (
|
|
27
|
+
* parentName: string,
|
|
28
|
+
* state: { name: string; type: string; required: boolean },
|
|
29
|
+
* ) => void;
|
|
30
|
+
* onDeleteNested?: (parentName: string, childName: string) => void;
|
|
31
|
+
* onToggleNestedRequired?: (parentName: string, childName: string) => void;
|
|
32
|
+
* onRenameNested?: (parentName: string, oldChild: string, newChild: string) => void;
|
|
33
|
+
* onChangeNestedType?: (parentName: string, childName: string, newType: string) => void;
|
|
34
|
+
* }} FieldHandlers
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a single schema field as an inline-editable form row.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} fieldName
|
|
41
|
+
* @param {SchemaProperty} fieldSchema — JSON Schema property definition
|
|
42
|
+
* @param {boolean} isRequired
|
|
43
|
+
* @param {FieldHandlers} handlers
|
|
44
|
+
* @returns {any}
|
|
45
|
+
*/
|
|
46
|
+
export function fieldCardTpl(fieldName, fieldSchema, isRequired, handlers) {
|
|
47
|
+
const type = fieldSchema.format === "date" ? "date" : fieldSchema.type || "string";
|
|
48
|
+
const isNested = type === "object";
|
|
49
|
+
const nestedProps = fieldSchema.properties || {};
|
|
50
|
+
const nestedRequired = fieldSchema.required || [];
|
|
51
|
+
|
|
52
|
+
return html`
|
|
53
|
+
<div class="schema-field-card">
|
|
54
|
+
<div class="schema-field-row">
|
|
55
|
+
<sp-textfield
|
|
56
|
+
size="s"
|
|
57
|
+
quiet
|
|
58
|
+
value=${fieldName}
|
|
59
|
+
class="schema-field-name-input"
|
|
60
|
+
@change=${(/** @type {any} */ e) => {
|
|
61
|
+
const newName = e.target.value.trim();
|
|
62
|
+
if (newName && newName !== fieldName) handlers.onRename(fieldName, newName);
|
|
63
|
+
else e.target.value = fieldName;
|
|
64
|
+
}}
|
|
65
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
66
|
+
if (e.key === "Enter") e.target.blur();
|
|
67
|
+
if (e.key === "Escape") {
|
|
68
|
+
e.target.value = fieldName;
|
|
69
|
+
e.target.blur();
|
|
70
|
+
}
|
|
71
|
+
}}
|
|
72
|
+
></sp-textfield>
|
|
73
|
+
${typePickerTpl(type, (newType) => handlers.onChangeType(fieldName, newType))}
|
|
74
|
+
<sp-switch
|
|
75
|
+
size="s"
|
|
76
|
+
?checked=${isRequired}
|
|
77
|
+
@change=${() => handlers.onToggleRequired(fieldName)}
|
|
78
|
+
>
|
|
79
|
+
Req
|
|
80
|
+
</sp-switch>
|
|
81
|
+
<sp-action-button
|
|
82
|
+
size="xs"
|
|
83
|
+
quiet
|
|
84
|
+
title="Delete field"
|
|
85
|
+
@click=${() => handlers.onDelete(fieldName)}
|
|
86
|
+
>
|
|
87
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
88
|
+
</sp-action-button>
|
|
89
|
+
</div>
|
|
90
|
+
${isNested
|
|
91
|
+
? html`
|
|
92
|
+
<div class="schema-field-nested">
|
|
93
|
+
${Object.entries(nestedProps).map(([name, sub]) =>
|
|
94
|
+
nestedFieldCardTpl(
|
|
95
|
+
fieldName,
|
|
96
|
+
name,
|
|
97
|
+
/** @type {SchemaProperty} */ (sub),
|
|
98
|
+
nestedRequired.includes(name),
|
|
99
|
+
handlers,
|
|
100
|
+
),
|
|
101
|
+
)}
|
|
102
|
+
${nestedAddFieldTpl(fieldName, handlers)}
|
|
103
|
+
</div>
|
|
104
|
+
`
|
|
105
|
+
: nothing}
|
|
106
|
+
</div>
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Render a nested (child) field card — same inline-editable pattern but delegates to nested
|
|
112
|
+
* handlers.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} parentName
|
|
115
|
+
* @param {string} childName
|
|
116
|
+
* @param {SchemaProperty} childSchema
|
|
117
|
+
* @param {boolean} isRequired
|
|
118
|
+
* @param {FieldHandlers} handlers
|
|
119
|
+
* @returns {any}
|
|
120
|
+
*/
|
|
121
|
+
function nestedFieldCardTpl(parentName, childName, childSchema, isRequired, handlers) {
|
|
122
|
+
const type = childSchema.format === "date" ? "date" : childSchema.type || "string";
|
|
123
|
+
|
|
124
|
+
return html`
|
|
125
|
+
<div class="schema-field-card schema-field-card--nested">
|
|
126
|
+
<div class="schema-field-row">
|
|
127
|
+
<sp-textfield
|
|
128
|
+
size="s"
|
|
129
|
+
quiet
|
|
130
|
+
value=${childName}
|
|
131
|
+
class="schema-field-name-input"
|
|
132
|
+
@change=${(/** @type {any} */ e) => {
|
|
133
|
+
const newName = e.target.value.trim();
|
|
134
|
+
if (newName && newName !== childName && handlers.onRenameNested) {
|
|
135
|
+
handlers.onRenameNested(parentName, childName, newName);
|
|
136
|
+
} else {
|
|
137
|
+
e.target.value = childName;
|
|
138
|
+
}
|
|
139
|
+
}}
|
|
140
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
141
|
+
if (e.key === "Enter") e.target.blur();
|
|
142
|
+
if (e.key === "Escape") {
|
|
143
|
+
e.target.value = childName;
|
|
144
|
+
e.target.blur();
|
|
145
|
+
}
|
|
146
|
+
}}
|
|
147
|
+
></sp-textfield>
|
|
148
|
+
${typePickerTpl(type, (newType) => {
|
|
149
|
+
if (handlers.onChangeNestedType)
|
|
150
|
+
handlers.onChangeNestedType(parentName, childName, newType);
|
|
151
|
+
})}
|
|
152
|
+
<sp-switch
|
|
153
|
+
size="s"
|
|
154
|
+
?checked=${isRequired}
|
|
155
|
+
@change=${() => {
|
|
156
|
+
if (handlers.onToggleNestedRequired)
|
|
157
|
+
handlers.onToggleNestedRequired(parentName, childName);
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
Req
|
|
161
|
+
</sp-switch>
|
|
162
|
+
<sp-action-button
|
|
163
|
+
size="xs"
|
|
164
|
+
quiet
|
|
165
|
+
title="Delete field"
|
|
166
|
+
@click=${() => {
|
|
167
|
+
if (handlers.onDeleteNested) handlers.onDeleteNested(parentName, childName);
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
171
|
+
</sp-action-button>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render an inline "Add Field" row for nested objects.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} parentName
|
|
181
|
+
* @param {FieldHandlers} handlers
|
|
182
|
+
* @returns {any}
|
|
183
|
+
*/
|
|
184
|
+
function nestedAddFieldTpl(parentName, handlers) {
|
|
185
|
+
return html`
|
|
186
|
+
<div class="schema-nested-add">
|
|
187
|
+
<sp-textfield
|
|
188
|
+
size="s"
|
|
189
|
+
placeholder="field name"
|
|
190
|
+
class="schema-nested-add-name"
|
|
191
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
192
|
+
if (e.key === "Enter") {
|
|
193
|
+
const row = e.target.closest(".schema-nested-add");
|
|
194
|
+
const name = e.target.value.trim();
|
|
195
|
+
const typePicker = row?.querySelector("sp-picker");
|
|
196
|
+
const type = typePicker?.value || "string";
|
|
197
|
+
if (name && handlers.onAddNestedField) {
|
|
198
|
+
handlers.onAddNestedField(parentName, { name, type, required: false });
|
|
199
|
+
e.target.value = "";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}}
|
|
203
|
+
></sp-textfield>
|
|
204
|
+
${typePickerTpl("string", () => {})}
|
|
205
|
+
<sp-action-button
|
|
206
|
+
size="xs"
|
|
207
|
+
quiet
|
|
208
|
+
title="Add nested field"
|
|
209
|
+
@click=${(/** @type {any} */ e) => {
|
|
210
|
+
const row = e.target.closest(".schema-nested-add");
|
|
211
|
+
const nameInput = /** @type {any} */ (row?.querySelector(".schema-nested-add-name"));
|
|
212
|
+
const typePicker = /** @type {any} */ (row?.querySelector("sp-picker"));
|
|
213
|
+
const name = nameInput?.value?.trim();
|
|
214
|
+
const type = typePicker?.value || "string";
|
|
215
|
+
if (name && handlers.onAddNestedField) {
|
|
216
|
+
handlers.onAddNestedField(parentName, { name, type, required: false });
|
|
217
|
+
nameInput.value = "";
|
|
218
|
+
}
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
<sp-icon-add slot="icon"></sp-icon-add>
|
|
222
|
+
</sp-action-button>
|
|
223
|
+
</div>
|
|
224
|
+
`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Render the type picker as an sp-picker dropdown.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} value
|
|
231
|
+
* @param {(type: string) => void} onChange
|
|
232
|
+
* @returns {any}
|
|
233
|
+
*/
|
|
234
|
+
export function typePickerTpl(value, onChange) {
|
|
235
|
+
return html`
|
|
236
|
+
<sp-picker
|
|
237
|
+
size="s"
|
|
238
|
+
label="Type"
|
|
239
|
+
value=${value}
|
|
240
|
+
@change=${(/** @type {any} */ e) => onChange(e.target.value)}
|
|
241
|
+
>
|
|
242
|
+
${FIELD_TYPES.map((t) => html`<sp-menu-item value=${t}>${t}</sp-menu-item>`)}
|
|
243
|
+
</sp-picker>
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Render the add-field form (inline, not a dialog).
|
|
249
|
+
*
|
|
250
|
+
* @param {{ name: string; type: string; required: boolean }} state
|
|
251
|
+
* @param {{
|
|
252
|
+
* onInput: (field: string, value: any) => void;
|
|
253
|
+
* onConfirm: () => void;
|
|
254
|
+
* onCancel: () => void;
|
|
255
|
+
* }} handlers
|
|
256
|
+
* @returns {any}
|
|
257
|
+
*/
|
|
258
|
+
export function addFieldFormTpl(state, handlers) {
|
|
259
|
+
return html`
|
|
260
|
+
<div class="schema-add-field">
|
|
261
|
+
<sp-textfield
|
|
262
|
+
size="s"
|
|
263
|
+
placeholder="Field name"
|
|
264
|
+
.value=${state.name}
|
|
265
|
+
@input=${(/** @type {any} */ e) => handlers.onInput("name", e.target.value)}
|
|
266
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
267
|
+
if (e.key === "Enter") handlers.onConfirm();
|
|
268
|
+
if (e.key === "Escape") handlers.onCancel();
|
|
269
|
+
}}
|
|
270
|
+
></sp-textfield>
|
|
271
|
+
${typePickerTpl(state.type, (t) => handlers.onInput("type", t))}
|
|
272
|
+
<sp-switch
|
|
273
|
+
size="s"
|
|
274
|
+
?checked=${state.required}
|
|
275
|
+
@change=${(/** @type {any} */ e) => handlers.onInput("required", e.target.checked)}
|
|
276
|
+
>
|
|
277
|
+
Required
|
|
278
|
+
</sp-switch>
|
|
279
|
+
<sp-action-button size="s" @click=${handlers.onConfirm}>Add</sp-action-button>
|
|
280
|
+
<sp-action-button size="s" quiet @click=${handlers.onCancel}>Cancel</sp-action-button>
|
|
281
|
+
</div>
|
|
282
|
+
`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build a JSON Schema property definition from a type string.
|
|
287
|
+
*
|
|
288
|
+
* @param {string} type
|
|
289
|
+
* @returns {object}
|
|
290
|
+
*/
|
|
291
|
+
export function schemaForType(type) {
|
|
292
|
+
switch (type) {
|
|
293
|
+
case "number":
|
|
294
|
+
return { type: "number" };
|
|
295
|
+
case "boolean":
|
|
296
|
+
return { type: "boolean" };
|
|
297
|
+
case "array":
|
|
298
|
+
return { type: "array", items: { type: "string" } };
|
|
299
|
+
case "object":
|
|
300
|
+
return { type: "object", properties: {}, required: [] };
|
|
301
|
+
case "date":
|
|
302
|
+
return { type: "string", format: "date" };
|
|
303
|
+
default:
|
|
304
|
+
return { type: "string" };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Generate a YAML frontmatter default value for a given schema type.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} type
|
|
312
|
+
* @param {string} [format]
|
|
313
|
+
* @returns {string}
|
|
314
|
+
*/
|
|
315
|
+
export function yamlDefault(type, format) {
|
|
316
|
+
if (format === "date") return new Date().toISOString().split("T")[0];
|
|
317
|
+
switch (type) {
|
|
318
|
+
case "boolean":
|
|
319
|
+
return "false";
|
|
320
|
+
case "number":
|
|
321
|
+
return "0";
|
|
322
|
+
case "array":
|
|
323
|
+
return "[]";
|
|
324
|
+
case "object":
|
|
325
|
+
return "{}";
|
|
326
|
+
default:
|
|
327
|
+
return '""';
|
|
328
|
+
}
|
|
329
|
+
}
|
package/src/state.js
CHANGED
|
@@ -129,8 +129,8 @@ export function flattenTree(doc, path = [], depth = 0) {
|
|
|
129
129
|
/** @type {{ node: any; path: JxPath; depth: number; nodeType: string }[]} */
|
|
130
130
|
const rows = [{ node: doc, path, depth, nodeType: "element" }];
|
|
131
131
|
|
|
132
|
-
// Custom component instances are atomic in the layer tree
|
|
133
|
-
if (doc.$props && (doc.tagName || "").includes("-")) {
|
|
132
|
+
// Custom component instances without user-authored children are atomic in the layer tree
|
|
133
|
+
if (doc.$props && (doc.tagName || "").includes("-") && !Array.isArray(doc.children)) {
|
|
134
134
|
return rows;
|
|
135
135
|
}
|
|
136
136
|
|
|
@@ -226,10 +226,63 @@ export function createState(doc) {
|
|
|
226
226
|
stylebookTab: "elements", // "elements" | "variables"
|
|
227
227
|
stylebookFilter: "", // search filter text
|
|
228
228
|
stylebookCustomizedOnly: false, // show only customized elements
|
|
229
|
+
settingsTab: "stylebook", // "stylebook" | "definitions" | "collections"
|
|
229
230
|
},
|
|
230
231
|
};
|
|
231
232
|
}
|
|
232
233
|
|
|
234
|
+
// ─── Doc/Session slice helpers ───────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Compose a flat StudioState from separate doc and session slices.
|
|
238
|
+
*
|
|
239
|
+
* @param {any} doc
|
|
240
|
+
* @param {any} session
|
|
241
|
+
* @returns {StudioState}
|
|
242
|
+
*/
|
|
243
|
+
export function toFlat(doc, session) {
|
|
244
|
+
return { ...doc, ...session };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Decompose a flat StudioState into doc and session slices.
|
|
249
|
+
*
|
|
250
|
+
* @param {StudioState} S
|
|
251
|
+
* @returns {{ doc: any; session: any }}
|
|
252
|
+
*/
|
|
253
|
+
export function fromFlat(S) {
|
|
254
|
+
const {
|
|
255
|
+
document,
|
|
256
|
+
dirty,
|
|
257
|
+
fileHandle,
|
|
258
|
+
documentPath,
|
|
259
|
+
documentStack,
|
|
260
|
+
handlersSource,
|
|
261
|
+
mode,
|
|
262
|
+
content,
|
|
263
|
+
history,
|
|
264
|
+
historyIndex,
|
|
265
|
+
selection,
|
|
266
|
+
hover,
|
|
267
|
+
ui,
|
|
268
|
+
} = S;
|
|
269
|
+
return {
|
|
270
|
+
doc: {
|
|
271
|
+
document,
|
|
272
|
+
dirty,
|
|
273
|
+
fileHandle,
|
|
274
|
+
documentPath,
|
|
275
|
+
documentStack,
|
|
276
|
+
handlersSource,
|
|
277
|
+
mode,
|
|
278
|
+
content,
|
|
279
|
+
history,
|
|
280
|
+
historyIndex,
|
|
281
|
+
},
|
|
282
|
+
session: { selection, hover, ui },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
233
286
|
// ─── Project state (persists across document switches) ────────────────────────
|
|
234
287
|
//
|
|
235
288
|
// Shape: { root, name, projectRoot, isSiteProject, projectConfig,
|
|
@@ -245,6 +298,28 @@ export function setProjectState(ps) {
|
|
|
245
298
|
projectState = ps;
|
|
246
299
|
}
|
|
247
300
|
|
|
301
|
+
// ─── Frontmatter mutation ───────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Update a frontmatter field. Does not use applyMutation because frontmatter lives in S.content,
|
|
305
|
+
* not S.document.
|
|
306
|
+
*
|
|
307
|
+
* @param {StudioState} state
|
|
308
|
+
* @param {string} field
|
|
309
|
+
* @param {any} value
|
|
310
|
+
* @returns {StudioState}
|
|
311
|
+
*/
|
|
312
|
+
export function updateFrontmatter(state, field, value) {
|
|
313
|
+
const fm = { ...state.content?.frontmatter };
|
|
314
|
+
if (value === undefined || value === null || value === "") delete fm[field];
|
|
315
|
+
else fm[field] = value;
|
|
316
|
+
return {
|
|
317
|
+
...state,
|
|
318
|
+
content: { ...state.content, frontmatter: fm },
|
|
319
|
+
dirty: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
248
323
|
// ─── Core mutation ────────────────────────────────────────────────────────────
|
|
249
324
|
|
|
250
325
|
/**
|
|
@@ -377,6 +452,28 @@ export function duplicateNode(state, path) {
|
|
|
377
452
|
return selectNode(newState, [...elemPath, "children", idx + 1]);
|
|
378
453
|
}
|
|
379
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Wrap the node at `path` in a new wrapper element (e.g. a div).
|
|
457
|
+
*
|
|
458
|
+
* @param {StudioState} state
|
|
459
|
+
* @param {JxPath} path
|
|
460
|
+
* @param {string} wrapperTag
|
|
461
|
+
* @returns {StudioState}
|
|
462
|
+
*/
|
|
463
|
+
export function wrapNode(state, path, wrapperTag = "div") {
|
|
464
|
+
if (!path || path.length < 2) return state;
|
|
465
|
+
const node = getNodeAtPath(state.document, path);
|
|
466
|
+
if (!node) return state;
|
|
467
|
+
const elemPath = /** @type {JxPath} */ (parentElementPath(path));
|
|
468
|
+
const idx = /** @type {number} */ (childIndex(path));
|
|
469
|
+
const wrapper = { tagName: wrapperTag, children: [structuredClone(node)] };
|
|
470
|
+
const newState = applyMutation(state, (doc) => {
|
|
471
|
+
const parent = getNodeAtPath(doc, elemPath);
|
|
472
|
+
parent.children.splice(idx, 1, wrapper);
|
|
473
|
+
});
|
|
474
|
+
return selectNode(newState, [...elemPath, "children", idx]);
|
|
475
|
+
}
|
|
476
|
+
|
|
380
477
|
/**
|
|
381
478
|
* @param {StudioState} state
|
|
382
479
|
* @param {JxPath} fromPath
|
package/src/store.js
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
insertNode,
|
|
18
18
|
removeNode,
|
|
19
19
|
duplicateNode,
|
|
20
|
+
wrapNode,
|
|
20
21
|
moveNode,
|
|
21
22
|
updateProperty,
|
|
22
23
|
updateStyle,
|
|
@@ -46,6 +47,9 @@ export {
|
|
|
46
47
|
isAncestor,
|
|
47
48
|
projectState,
|
|
48
49
|
setProjectState,
|
|
50
|
+
updateFrontmatter,
|
|
51
|
+
toFlat,
|
|
52
|
+
fromFlat,
|
|
49
53
|
} from "./state.js";
|
|
50
54
|
|
|
51
55
|
// ─── DOM shortcuts & element refs ────────────────────────────────────────────
|
|
@@ -60,45 +64,6 @@ export const rightPanel = /** @type {any} */ (document.querySelector("#right-pan
|
|
|
60
64
|
export const toolbarEl = /** @type {any} */ (document.querySelector("#toolbar"));
|
|
61
65
|
export const statusbarEl = /** @type {any} */ (document.querySelector("#statusbar"));
|
|
62
66
|
|
|
63
|
-
// ─── Shared mutable state container ─────────────────────────────────────────
|
|
64
|
-
// A plain object so all importers share the same reference and see mutations.
|
|
65
|
-
// Used by extracted modules; studio.js keeps local aliases during migration.
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @type {{
|
|
69
|
-
* S: any;
|
|
70
|
-
* canvasMode: string;
|
|
71
|
-
* panX: number;
|
|
72
|
-
* panY: number;
|
|
73
|
-
* panzoomWrap: any;
|
|
74
|
-
* componentInlineEdit: any;
|
|
75
|
-
* pendingInlineEdit: any;
|
|
76
|
-
* monacoEditor: any;
|
|
77
|
-
* functionEditor: any;
|
|
78
|
-
* liveScope: any;
|
|
79
|
-
* blockActionBarEl: any;
|
|
80
|
-
* inlineEditCleanup: any;
|
|
81
|
-
* selDragCleanup: any;
|
|
82
|
-
* componentSlashMenu: any;
|
|
83
|
-
* }}
|
|
84
|
-
*/
|
|
85
|
-
export const ctx = {
|
|
86
|
-
S: undefined,
|
|
87
|
-
canvasMode: "design",
|
|
88
|
-
panX: 0,
|
|
89
|
-
panY: 0,
|
|
90
|
-
panzoomWrap: null,
|
|
91
|
-
componentInlineEdit: null,
|
|
92
|
-
pendingInlineEdit: null,
|
|
93
|
-
monacoEditor: null,
|
|
94
|
-
functionEditor: null,
|
|
95
|
-
liveScope: null,
|
|
96
|
-
blockActionBarEl: null,
|
|
97
|
-
inlineEditCleanup: null,
|
|
98
|
-
selDragCleanup: null,
|
|
99
|
-
componentSlashMenu: null,
|
|
100
|
-
};
|
|
101
|
-
|
|
102
67
|
// ─── Shared containers (mutated in place by owner modules) ───────────────────
|
|
103
68
|
|
|
104
69
|
/** WeakMap<HTMLElement, Array> — maps rendered DOM elements to their JSON paths */
|
|
@@ -225,7 +190,13 @@ export function registerRenderer(name, fn) {
|
|
|
225
190
|
|
|
226
191
|
/** Call all registered renderers (full repaint). */
|
|
227
192
|
export function render() {
|
|
228
|
-
for (const fn of _renderers.
|
|
193
|
+
for (const [name, fn] of _renderers.entries()) {
|
|
194
|
+
try {
|
|
195
|
+
fn();
|
|
196
|
+
} catch (e) {
|
|
197
|
+
console.error(`Renderer "${name}" failed:`, e);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
229
200
|
}
|
|
230
201
|
|
|
231
202
|
/**
|
|
@@ -236,7 +207,12 @@ export function render() {
|
|
|
236
207
|
export function renderOnly(...names) {
|
|
237
208
|
for (const name of names) {
|
|
238
209
|
const fn = _renderers.get(name);
|
|
239
|
-
if (fn)
|
|
210
|
+
if (!fn) continue;
|
|
211
|
+
try {
|
|
212
|
+
fn();
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error(`Renderer "${name}" failed:`, e);
|
|
215
|
+
}
|
|
240
216
|
}
|
|
241
217
|
}
|
|
242
218
|
|
|
@@ -288,6 +264,101 @@ export function update(newState) {
|
|
|
288
264
|
_updateFn(newState);
|
|
289
265
|
}
|
|
290
266
|
|
|
267
|
+
// ─── Session dispatch (late-bound) ──────────────────────────────────────────
|
|
268
|
+
// Lightweight dispatcher for session-only changes (selection, hover, ui).
|
|
269
|
+
// Does NOT trigger autosave middleware or push history.
|
|
270
|
+
|
|
271
|
+
/** @type {Function} */
|
|
272
|
+
let _updateSessionFn = () => {
|
|
273
|
+
throw new Error("updateSession() called before setUpdateSessionFn() — bootstrap not complete");
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
/** @type {Function} */
|
|
277
|
+
let _getDocFn = () => null;
|
|
278
|
+
|
|
279
|
+
/** @type {Function} */
|
|
280
|
+
let _getSessionFn = () => null;
|
|
281
|
+
|
|
282
|
+
/** @param {Function} fn */
|
|
283
|
+
export function setUpdateSessionFn(fn) {
|
|
284
|
+
_updateSessionFn = fn;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** @param {Function} fn */
|
|
288
|
+
export function setGetDocFn(fn) {
|
|
289
|
+
_getDocFn = fn;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** @param {Function} fn */
|
|
293
|
+
export function setGetSessionFn(fn) {
|
|
294
|
+
_getSessionFn = fn;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @returns {any} */
|
|
298
|
+
export function getDoc() {
|
|
299
|
+
return _getDocFn();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** @returns {any} */
|
|
303
|
+
export function getSession() {
|
|
304
|
+
return _getSessionFn();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Dispatch a session-only state update (selection, hover, ui). Does not trigger autosave.
|
|
309
|
+
*
|
|
310
|
+
* @param {any} patch — partial session object, e.g. { ui: { zoom: 2 } }
|
|
311
|
+
*/
|
|
312
|
+
export function updateSession(patch) {
|
|
313
|
+
_updateSessionFn(patch);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Update a single UI field and re-render. Routes through session dispatch (no autosave, no
|
|
318
|
+
* history).
|
|
319
|
+
*
|
|
320
|
+
* @param {string} field
|
|
321
|
+
* @param {any} value
|
|
322
|
+
*/
|
|
323
|
+
export function updateUi(field, value) {
|
|
324
|
+
_updateSessionFn({ ui: { [field]: value } });
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ─── Subscription system ────────────────────────────────────────────────────
|
|
328
|
+
// Panels subscribe to state changes and decide when to re-render, rather than
|
|
329
|
+
// being called unconditionally from _update/_updateSession.
|
|
330
|
+
|
|
331
|
+
/** @typedef {{ doc: boolean; selection: boolean; hover: boolean; ui: boolean; mode: boolean }} Change */
|
|
332
|
+
|
|
333
|
+
/** @type {Set<(change: Change) => void>} */
|
|
334
|
+
const _subscribers = new Set();
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Subscribe to state changes. Returns an unsubscribe function.
|
|
338
|
+
*
|
|
339
|
+
* @param {(change: Change) => void} fn
|
|
340
|
+
* @returns {() => void}
|
|
341
|
+
*/
|
|
342
|
+
export function subscribe(fn) {
|
|
343
|
+
_subscribers.add(fn);
|
|
344
|
+
return () => _subscribers.delete(fn);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Notify all subscribers of a state change.
|
|
349
|
+
*
|
|
350
|
+
* @param {Change} change
|
|
351
|
+
*/
|
|
352
|
+
export function notify(change) {
|
|
353
|
+
for (const fn of _subscribers) {
|
|
354
|
+
try {
|
|
355
|
+
fn(change);
|
|
356
|
+
} catch (e) {
|
|
357
|
+
console.error("Subscriber failed:", e);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
291
362
|
/** @type {Function[]} */
|
|
292
363
|
const _updateMiddleware = [];
|
|
293
364
|
|