@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,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections Editor — visual schema builder for project content collections.
|
|
3
|
+
*
|
|
4
|
+
* Renders inside the Settings view "Collections" tab. Two-column layout: left column lists
|
|
5
|
+
* collection names, right column edits the selected collection's schema.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { html, render as litRender } from "lit-html";
|
|
9
|
+
import { getPlatform } from "../platform.js";
|
|
10
|
+
import { projectState } from "../store.js";
|
|
11
|
+
import { fieldCardTpl, addFieldFormTpl, schemaForType } from "./schema-field-ui.js";
|
|
12
|
+
|
|
13
|
+
// ─── Module state ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** @type {string | null} */
|
|
16
|
+
let selectedCollection = null;
|
|
17
|
+
let showAddField = false;
|
|
18
|
+
let newFieldState = { name: "", type: "string", required: false };
|
|
19
|
+
let showNewCollection = false;
|
|
20
|
+
let newCollectionName = "";
|
|
21
|
+
|
|
22
|
+
// ─── Persistence ──────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
async function saveProjectConfig() {
|
|
25
|
+
const platform = getPlatform();
|
|
26
|
+
const config = /** @type {any} */ (projectState).projectConfig;
|
|
27
|
+
await platform.writeFile("project.json", JSON.stringify(config, null, "\t"));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Get the schema object for the selected collection. */
|
|
33
|
+
function getSelectedSchema() {
|
|
34
|
+
const config = projectState?.projectConfig;
|
|
35
|
+
return config?.collections?.[/** @type {string} */ (selectedCollection)]?.schema;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Handlers ─────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/** @param {() => void} rerender */
|
|
41
|
+
function handleNewCollection(rerender) {
|
|
42
|
+
const slug = newCollectionName
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/\s+/g, "-")
|
|
45
|
+
.replace(/[^a-z0-9-]/g, "");
|
|
46
|
+
if (!slug) return;
|
|
47
|
+
|
|
48
|
+
const config = projectState?.projectConfig;
|
|
49
|
+
if (!config) return;
|
|
50
|
+
if (!config.collections) config.collections = {};
|
|
51
|
+
if (config.collections[slug]) return; // already exists
|
|
52
|
+
|
|
53
|
+
config.collections[slug] = {
|
|
54
|
+
source: `./${slug}/**/*.md`,
|
|
55
|
+
schema: { type: "object", properties: {}, required: [] },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
selectedCollection = slug;
|
|
59
|
+
showNewCollection = false;
|
|
60
|
+
newCollectionName = "";
|
|
61
|
+
rerender();
|
|
62
|
+
|
|
63
|
+
// Persist in background
|
|
64
|
+
saveProjectConfig().then(async () => {
|
|
65
|
+
const platform = getPlatform();
|
|
66
|
+
await platform.writeFile(`${slug}/.gitkeep`, "");
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {() => void} rerender */
|
|
71
|
+
function handleAddField(rerender) {
|
|
72
|
+
const name = newFieldState.name.trim();
|
|
73
|
+
if (!name || !selectedCollection) return;
|
|
74
|
+
|
|
75
|
+
const schema = getSelectedSchema();
|
|
76
|
+
if (!schema) return;
|
|
77
|
+
|
|
78
|
+
if (!schema.properties) schema.properties = {};
|
|
79
|
+
schema.properties[name] = schemaForType(newFieldState.type);
|
|
80
|
+
|
|
81
|
+
if (newFieldState.required) {
|
|
82
|
+
if (!schema.required) schema.required = [];
|
|
83
|
+
if (!schema.required.includes(name)) schema.required.push(name);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
showAddField = false;
|
|
87
|
+
newFieldState = { name: "", type: "string", required: false };
|
|
88
|
+
rerender();
|
|
89
|
+
saveProjectConfig();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {string} fieldName
|
|
94
|
+
* @param {() => void} rerender
|
|
95
|
+
*/
|
|
96
|
+
function handleDeleteField(fieldName, rerender) {
|
|
97
|
+
const schema = getSelectedSchema();
|
|
98
|
+
if (!schema?.properties) return;
|
|
99
|
+
|
|
100
|
+
delete schema.properties[fieldName];
|
|
101
|
+
if (schema.required) {
|
|
102
|
+
schema.required = schema.required.filter((/** @type {string} */ r) => r !== fieldName);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
rerender();
|
|
106
|
+
saveProjectConfig();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* @param {string} fieldName
|
|
111
|
+
* @param {() => void} rerender
|
|
112
|
+
*/
|
|
113
|
+
function handleToggleRequired(fieldName, rerender) {
|
|
114
|
+
const schema = getSelectedSchema();
|
|
115
|
+
if (!schema) return;
|
|
116
|
+
if (!schema.required) schema.required = [];
|
|
117
|
+
|
|
118
|
+
const idx = schema.required.indexOf(fieldName);
|
|
119
|
+
if (idx >= 0) schema.required.splice(idx, 1);
|
|
120
|
+
else schema.required.push(fieldName);
|
|
121
|
+
|
|
122
|
+
rerender();
|
|
123
|
+
saveProjectConfig();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* @param {string} oldName
|
|
128
|
+
* @param {string} newName
|
|
129
|
+
* @param {() => void} rerender
|
|
130
|
+
*/
|
|
131
|
+
function handleRenameField(oldName, newName, rerender) {
|
|
132
|
+
const schema = getSelectedSchema();
|
|
133
|
+
if (!schema?.properties || !newName || schema.properties[newName]) return;
|
|
134
|
+
|
|
135
|
+
/** @type {Record<string, any>} */
|
|
136
|
+
const newProps = {};
|
|
137
|
+
for (const [key, val] of Object.entries(schema.properties)) {
|
|
138
|
+
newProps[key === oldName ? newName : key] = val;
|
|
139
|
+
}
|
|
140
|
+
schema.properties = newProps;
|
|
141
|
+
|
|
142
|
+
if (schema.required) {
|
|
143
|
+
schema.required = schema.required.map((/** @type {string} */ r) =>
|
|
144
|
+
r === oldName ? newName : r,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
rerender();
|
|
149
|
+
saveProjectConfig();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string} fieldName
|
|
154
|
+
* @param {string} newType
|
|
155
|
+
* @param {() => void} rerender
|
|
156
|
+
*/
|
|
157
|
+
function handleChangeType(fieldName, newType, rerender) {
|
|
158
|
+
const schema = getSelectedSchema();
|
|
159
|
+
if (!schema?.properties?.[fieldName]) return;
|
|
160
|
+
|
|
161
|
+
schema.properties[fieldName] = schemaForType(newType);
|
|
162
|
+
rerender();
|
|
163
|
+
saveProjectConfig();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Nested field handlers ───────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {string} parentName
|
|
170
|
+
* @param {{ name: string; type: string; required: boolean }} fieldState
|
|
171
|
+
* @param {() => void} rerender
|
|
172
|
+
*/
|
|
173
|
+
function handleAddNestedField(parentName, fieldState, rerender) {
|
|
174
|
+
const schema = getSelectedSchema();
|
|
175
|
+
const parent = schema?.properties?.[parentName];
|
|
176
|
+
if (!parent) return;
|
|
177
|
+
|
|
178
|
+
if (!parent.properties) parent.properties = {};
|
|
179
|
+
parent.properties[fieldState.name] = schemaForType(fieldState.type);
|
|
180
|
+
|
|
181
|
+
if (fieldState.required) {
|
|
182
|
+
if (!parent.required) parent.required = [];
|
|
183
|
+
if (!parent.required.includes(fieldState.name)) parent.required.push(fieldState.name);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
rerender();
|
|
187
|
+
saveProjectConfig();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {string} parentName
|
|
192
|
+
* @param {string} childName
|
|
193
|
+
* @param {() => void} rerender
|
|
194
|
+
*/
|
|
195
|
+
function handleDeleteNested(parentName, childName, rerender) {
|
|
196
|
+
const schema = getSelectedSchema();
|
|
197
|
+
const parent = schema?.properties?.[parentName];
|
|
198
|
+
if (!parent?.properties) return;
|
|
199
|
+
|
|
200
|
+
delete parent.properties[childName];
|
|
201
|
+
if (parent.required) {
|
|
202
|
+
parent.required = parent.required.filter((/** @type {string} */ r) => r !== childName);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
rerender();
|
|
206
|
+
saveProjectConfig();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @param {string} parentName
|
|
211
|
+
* @param {string} childName
|
|
212
|
+
* @param {() => void} rerender
|
|
213
|
+
*/
|
|
214
|
+
function handleToggleNestedRequired(parentName, childName, rerender) {
|
|
215
|
+
const schema = getSelectedSchema();
|
|
216
|
+
const parent = schema?.properties?.[parentName];
|
|
217
|
+
if (!parent) return;
|
|
218
|
+
if (!parent.required) parent.required = [];
|
|
219
|
+
|
|
220
|
+
const idx = parent.required.indexOf(childName);
|
|
221
|
+
if (idx >= 0) parent.required.splice(idx, 1);
|
|
222
|
+
else parent.required.push(childName);
|
|
223
|
+
|
|
224
|
+
rerender();
|
|
225
|
+
saveProjectConfig();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} parentName
|
|
230
|
+
* @param {string} oldChild
|
|
231
|
+
* @param {string} newChild
|
|
232
|
+
* @param {() => void} rerender
|
|
233
|
+
*/
|
|
234
|
+
function handleRenameNested(parentName, oldChild, newChild, rerender) {
|
|
235
|
+
const schema = getSelectedSchema();
|
|
236
|
+
const parent = schema?.properties?.[parentName];
|
|
237
|
+
if (!parent?.properties || !newChild || parent.properties[newChild]) return;
|
|
238
|
+
|
|
239
|
+
/** @type {Record<string, any>} */
|
|
240
|
+
const newProps = {};
|
|
241
|
+
for (const [key, val] of Object.entries(parent.properties)) {
|
|
242
|
+
newProps[key === oldChild ? newChild : key] = val;
|
|
243
|
+
}
|
|
244
|
+
parent.properties = newProps;
|
|
245
|
+
|
|
246
|
+
if (parent.required) {
|
|
247
|
+
parent.required = parent.required.map((/** @type {string} */ r) =>
|
|
248
|
+
r === oldChild ? newChild : r,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
rerender();
|
|
253
|
+
saveProjectConfig();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {string} parentName
|
|
258
|
+
* @param {string} childName
|
|
259
|
+
* @param {string} newType
|
|
260
|
+
* @param {() => void} rerender
|
|
261
|
+
*/
|
|
262
|
+
function handleChangeNestedType(parentName, childName, newType, rerender) {
|
|
263
|
+
const schema = getSelectedSchema();
|
|
264
|
+
const parent = schema?.properties?.[parentName];
|
|
265
|
+
if (!parent?.properties?.[childName]) return;
|
|
266
|
+
|
|
267
|
+
parent.properties[childName] = schemaForType(newType);
|
|
268
|
+
rerender();
|
|
269
|
+
saveProjectConfig();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** @param {() => void} rerender */
|
|
273
|
+
function handleDeleteCollection(rerender) {
|
|
274
|
+
if (!selectedCollection) return;
|
|
275
|
+
const config = projectState?.projectConfig;
|
|
276
|
+
if (!config?.collections?.[selectedCollection]) return;
|
|
277
|
+
|
|
278
|
+
delete config.collections[selectedCollection];
|
|
279
|
+
selectedCollection = null;
|
|
280
|
+
|
|
281
|
+
rerender();
|
|
282
|
+
saveProjectConfig();
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Render the collections editor.
|
|
289
|
+
*
|
|
290
|
+
* @param {HTMLElement} container
|
|
291
|
+
*/
|
|
292
|
+
export function renderCollectionsEditor(container) {
|
|
293
|
+
const rerender = () => renderCollectionsEditor(container);
|
|
294
|
+
const config = projectState?.projectConfig;
|
|
295
|
+
const collections = config?.collections || {};
|
|
296
|
+
const collectionNames = Object.keys(collections);
|
|
297
|
+
|
|
298
|
+
// Left column — collection list
|
|
299
|
+
const listTpl = html`
|
|
300
|
+
<div class="settings-list-panel">
|
|
301
|
+
${collectionNames.map(
|
|
302
|
+
(name) => html`
|
|
303
|
+
<sp-action-button
|
|
304
|
+
size="s"
|
|
305
|
+
?selected=${selectedCollection === name}
|
|
306
|
+
@click=${() => {
|
|
307
|
+
selectedCollection = name;
|
|
308
|
+
showAddField = false;
|
|
309
|
+
rerender();
|
|
310
|
+
}}
|
|
311
|
+
>
|
|
312
|
+
${name}
|
|
313
|
+
</sp-action-button>
|
|
314
|
+
`,
|
|
315
|
+
)}
|
|
316
|
+
${showNewCollection
|
|
317
|
+
? html`
|
|
318
|
+
<div class="settings-inline-form">
|
|
319
|
+
<sp-textfield
|
|
320
|
+
size="s"
|
|
321
|
+
placeholder="collection-name"
|
|
322
|
+
.value=${newCollectionName}
|
|
323
|
+
@input=${(/** @type {any} */ e) => {
|
|
324
|
+
newCollectionName = e.target.value;
|
|
325
|
+
}}
|
|
326
|
+
@keydown=${(/** @type {any} */ e) => {
|
|
327
|
+
if (e.key === "Enter") handleNewCollection(rerender);
|
|
328
|
+
if (e.key === "Escape") {
|
|
329
|
+
showNewCollection = false;
|
|
330
|
+
rerender();
|
|
331
|
+
}
|
|
332
|
+
}}
|
|
333
|
+
></sp-textfield>
|
|
334
|
+
<sp-action-button size="s" @click=${() => handleNewCollection(rerender)}>
|
|
335
|
+
Create
|
|
336
|
+
</sp-action-button>
|
|
337
|
+
</div>
|
|
338
|
+
`
|
|
339
|
+
: html`
|
|
340
|
+
<sp-action-button
|
|
341
|
+
size="s"
|
|
342
|
+
quiet
|
|
343
|
+
@click=${() => {
|
|
344
|
+
showNewCollection = true;
|
|
345
|
+
rerender();
|
|
346
|
+
}}
|
|
347
|
+
>
|
|
348
|
+
<sp-icon-add slot="icon"></sp-icon-add> New Collection
|
|
349
|
+
</sp-action-button>
|
|
350
|
+
`}
|
|
351
|
+
</div>
|
|
352
|
+
`;
|
|
353
|
+
|
|
354
|
+
// Right column — schema editor
|
|
355
|
+
let editorTpl;
|
|
356
|
+
if (!selectedCollection || !collections[selectedCollection]) {
|
|
357
|
+
editorTpl = html`<div class="settings-empty-state">Select or create a collection</div>`;
|
|
358
|
+
} else {
|
|
359
|
+
const col = collections[selectedCollection];
|
|
360
|
+
const schema = col.schema || {};
|
|
361
|
+
const properties = schema.properties || {};
|
|
362
|
+
const required = schema.required || [];
|
|
363
|
+
|
|
364
|
+
/** @type {import("./schema-field-ui.js").FieldHandlers} */
|
|
365
|
+
const handlers = {
|
|
366
|
+
onDelete: (n) => handleDeleteField(n, rerender),
|
|
367
|
+
onToggleRequired: (n) => handleToggleRequired(n, rerender),
|
|
368
|
+
onRename: (oldN, newN) => handleRenameField(oldN, newN, rerender),
|
|
369
|
+
onChangeType: (n, t) => handleChangeType(n, t, rerender),
|
|
370
|
+
onAddNestedField: (p, s) => handleAddNestedField(p, s, rerender),
|
|
371
|
+
onDeleteNested: (p, c) => handleDeleteNested(p, c, rerender),
|
|
372
|
+
onToggleNestedRequired: (p, c) => handleToggleNestedRequired(p, c, rerender),
|
|
373
|
+
onRenameNested: (p, o, n) => handleRenameNested(p, o, n, rerender),
|
|
374
|
+
onChangeNestedType: (p, c, t) => handleChangeNestedType(p, c, t, rerender),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const fieldCards = Object.entries(properties).map(([name, def]) =>
|
|
378
|
+
fieldCardTpl(name, /** @type {any} */ (def), required.includes(name), handlers),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
editorTpl = html`
|
|
382
|
+
<div class="settings-editor-panel">
|
|
383
|
+
<div class="settings-editor-header">
|
|
384
|
+
<h3>${selectedCollection}</h3>
|
|
385
|
+
<sp-field-label size="s">Source: ${col.source || "—"}</sp-field-label>
|
|
386
|
+
<sp-action-button
|
|
387
|
+
size="xs"
|
|
388
|
+
quiet
|
|
389
|
+
title="Delete collection"
|
|
390
|
+
@click=${() => handleDeleteCollection(rerender)}
|
|
391
|
+
>
|
|
392
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
393
|
+
</sp-action-button>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="schema-field-list">${fieldCards}</div>
|
|
396
|
+
${showAddField
|
|
397
|
+
? addFieldFormTpl(newFieldState, {
|
|
398
|
+
onInput: (field, value) => {
|
|
399
|
+
newFieldState = { ...newFieldState, [field]: value };
|
|
400
|
+
rerender();
|
|
401
|
+
},
|
|
402
|
+
onConfirm: () => handleAddField(rerender),
|
|
403
|
+
onCancel: () => {
|
|
404
|
+
showAddField = false;
|
|
405
|
+
newFieldState = { name: "", type: "string", required: false };
|
|
406
|
+
rerender();
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
: html`
|
|
410
|
+
<sp-action-button
|
|
411
|
+
size="s"
|
|
412
|
+
quiet
|
|
413
|
+
@click=${() => {
|
|
414
|
+
showAddField = true;
|
|
415
|
+
rerender();
|
|
416
|
+
}}
|
|
417
|
+
>
|
|
418
|
+
<sp-icon-add slot="icon"></sp-icon-add> Add Field
|
|
419
|
+
</sp-action-button>
|
|
420
|
+
`}
|
|
421
|
+
</div>
|
|
422
|
+
`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const tpl = html` <div class="settings-two-col">${listTpl} ${editorTpl}</div> `;
|
|
426
|
+
|
|
427
|
+
litRender(tpl, container);
|
|
428
|
+
}
|