@jxsuite/studio 0.28.5 → 0.30.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 +98 -98
- package/dist/studio.js +9368 -6586
- package/dist/studio.js.map +109 -83
- package/dist/workers/editor.worker.js +79 -79
- package/dist/workers/json.worker.js +109 -109
- package/dist/workers/ts.worker.js +82 -82
- package/package.json +11 -10
- package/src/browse/browse-modal.ts +2 -2
- package/src/browse/browse.ts +20 -19
- package/src/canvas/canvas-diff.ts +3 -3
- package/src/canvas/canvas-helpers.ts +15 -2
- package/src/canvas/canvas-live-render.ts +168 -86
- package/src/canvas/canvas-patcher.ts +656 -0
- package/src/canvas/canvas-perf.ts +68 -0
- package/src/canvas/canvas-render.ts +135 -23
- package/src/canvas/canvas-subtree-render.ts +113 -0
- package/src/canvas/canvas-utils.ts +4 -0
- package/src/editor/component-inline-edit.ts +4 -35
- package/src/editor/context-menu.ts +96 -76
- package/src/editor/convert-to-component.ts +11 -2
- package/src/editor/convert-to-repeater.ts +4 -5
- package/src/editor/inline-edit.ts +9 -9
- package/src/editor/inline-format.ts +11 -11
- package/src/editor/merge-tags.ts +120 -0
- package/src/editor/shortcuts.ts +4 -4
- package/src/files/components.ts +37 -0
- package/src/files/file-ops.ts +28 -7
- package/src/files/files.ts +68 -24
- package/src/files/fs-events.ts +162 -0
- package/src/github/github-auth.ts +14 -2
- package/src/github/github-publish.ts +12 -2
- package/src/panels/activity-bar.ts +1 -1
- package/src/panels/ai-panel.ts +67 -39
- package/src/panels/block-action-bar.ts +72 -1
- package/src/panels/canvas-dnd.ts +55 -26
- package/src/panels/data-explorer.ts +5 -3
- package/src/panels/dnd.ts +14 -17
- package/src/panels/editors.ts +5 -23
- package/src/panels/elements-panel.ts +2 -12
- package/src/panels/events-panel.ts +1 -1
- package/src/panels/git-panel.ts +6 -6
- package/src/panels/head-panel.ts +1 -1
- package/src/panels/layers-panel.ts +177 -147
- package/src/panels/preview-render.ts +15 -0
- package/src/panels/properties-panel.ts +16 -27
- package/src/panels/quick-search.ts +2 -2
- package/src/panels/right-panel.ts +2 -6
- package/src/panels/shared.ts +3 -0
- package/src/panels/signals-panel.ts +43 -33
- package/src/panels/statusbar.ts +15 -6
- package/src/panels/style-panel.ts +3 -3
- package/src/panels/style-utils.ts +3 -3
- package/src/panels/stylebook-panel.ts +4 -4
- package/src/panels/tab-bar.ts +198 -0
- package/src/panels/tab-strip.ts +2 -2
- package/src/panels/toolbar.ts +6 -75
- package/src/platforms/devserver.ts +113 -35
- package/src/reactivity.ts +2 -0
- package/src/services/cem-export.ts +23 -2
- package/src/services/code-services.ts +16 -2
- package/src/services/monaco-setup.ts +2 -1
- package/src/settings/content-types-editor.ts +16 -16
- package/src/settings/css-vars-editor.ts +2 -2
- package/src/settings/defs-editor.ts +14 -14
- package/src/settings/general-settings.ts +6 -6
- package/src/settings/head-editor.ts +2 -2
- package/src/site-context.ts +1 -1
- package/src/state.ts +66 -12
- package/src/store.ts +15 -3
- package/src/studio.ts +88 -35
- package/src/tabs/patch-ops.ts +143 -0
- package/src/tabs/tab.ts +15 -1
- package/src/tabs/transact.ts +454 -27
- package/src/types.ts +71 -1
- package/src/ui/color-selector.ts +7 -7
- package/src/ui/icons.ts +0 -30
- package/src/ui/media-picker.ts +2 -2
- package/src/ui/panel-resize.ts +14 -8
- package/src/ui/unit-selector.ts +5 -2
- package/src/ui/value-selector.ts +3 -3
- package/src/utils/canvas-media.ts +6 -6
- package/src/utils/edit-display.ts +125 -83
- package/src/utils/google-fonts.ts +1 -1
- package/src/utils/inherited-style.ts +3 -2
- package/src/utils/studio-utils.ts +1 -1
- package/src/view.ts +4 -1
|
@@ -70,10 +70,12 @@ function nodeToHtml(node: JxNode | string): string {
|
|
|
70
70
|
async function writeToClipboard(json: Record<string, unknown>) {
|
|
71
71
|
workspace.clipboard = json;
|
|
72
72
|
try {
|
|
73
|
+
const jxBlob = new Blob([JSON.stringify(json)], { type: JX_MIME });
|
|
74
|
+
const htmlBlob = new Blob([nodeToHtml(json)], { type: "text/html" });
|
|
73
75
|
await navigator.clipboard.write([
|
|
74
76
|
new ClipboardItem({
|
|
75
|
-
[JX_MIME]:
|
|
76
|
-
"text/html":
|
|
77
|
+
[JX_MIME]: jxBlob,
|
|
78
|
+
"text/html": htmlBlob,
|
|
77
79
|
}),
|
|
78
80
|
]);
|
|
79
81
|
} catch {
|
|
@@ -97,7 +99,7 @@ async function readFromClipboard() {
|
|
|
97
99
|
for (const item of items) {
|
|
98
100
|
if (item.types.includes(JX_MIME)) {
|
|
99
101
|
const blob = await item.getType(JX_MIME);
|
|
100
|
-
const json = JSON.parse(await blob.text());
|
|
102
|
+
const json = JSON.parse(await blob.text()) as JxNode;
|
|
101
103
|
return [json];
|
|
102
104
|
}
|
|
103
105
|
if (item.types.includes("text/html")) {
|
|
@@ -116,7 +118,7 @@ async function readFromClipboard() {
|
|
|
116
118
|
const text = await blob.text();
|
|
117
119
|
// Try parsing as Jx JSON
|
|
118
120
|
try {
|
|
119
|
-
const parsed = JSON.parse(text);
|
|
121
|
+
const parsed = JSON.parse(text) as JxNode;
|
|
120
122
|
if (parsed && parsed.tagName) {
|
|
121
123
|
return [parsed];
|
|
122
124
|
}
|
|
@@ -191,14 +193,14 @@ export async function pasteNode() {
|
|
|
191
193
|
const idx = childIndex(tab.session.selection) as number;
|
|
192
194
|
transactDoc(tab, (t) => {
|
|
193
195
|
for (let i = 0; i < nodes.length; i++) {
|
|
194
|
-
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
|
|
196
|
+
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
|
|
195
197
|
}
|
|
196
198
|
});
|
|
197
199
|
} else {
|
|
198
200
|
const idx = Array.isArray(parent.children) ? parent.children.length : 0;
|
|
199
201
|
transactDoc(tab, (t) => {
|
|
200
202
|
for (let i = 0; i < nodes.length; i++) {
|
|
201
|
-
mutateInsertNode(t, pPath, idx + i, nodes[i]);
|
|
203
|
+
mutateInsertNode(t, pPath, idx + i, nodes[i]!);
|
|
202
204
|
}
|
|
203
205
|
});
|
|
204
206
|
}
|
|
@@ -272,16 +274,23 @@ export function showContextMenu(
|
|
|
272
274
|
// Select the node
|
|
273
275
|
tab.session.selection = path;
|
|
274
276
|
|
|
277
|
+
// Index-based structural actions (cut/duplicate/insert/wrap/delete) require a numeric child
|
|
278
|
+
// Index. Repeater templates (path tail "map") and the document root don't have one — they get
|
|
279
|
+
// Copy only — so we never splice with a non-numeric index.
|
|
280
|
+
const idxIsNumber = typeof childIndex(path) === "number";
|
|
281
|
+
|
|
275
282
|
const items: { label: string; action?: () => void | Promise<void>; danger?: boolean }[] = [
|
|
276
283
|
{ action: () => copyNode(), label: "Copy" },
|
|
277
284
|
];
|
|
278
285
|
|
|
279
|
-
if (path.length >= 2) {
|
|
280
|
-
items.push(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
286
|
+
if (path.length >= 2 && idxIsNumber) {
|
|
287
|
+
items.push(
|
|
288
|
+
{ action: () => cutNode(), label: "Cut" },
|
|
289
|
+
{
|
|
290
|
+
action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
|
|
291
|
+
label: "Duplicate",
|
|
292
|
+
},
|
|
293
|
+
);
|
|
285
294
|
if (node.style) {
|
|
286
295
|
const nodeStyle = node.style;
|
|
287
296
|
items.push({
|
|
@@ -305,33 +314,36 @@ export function showContextMenu(
|
|
|
305
314
|
label: "Paste styles",
|
|
306
315
|
});
|
|
307
316
|
}
|
|
308
|
-
|
|
309
|
-
items.push(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
317
|
+
// Separator
|
|
318
|
+
items.push(
|
|
319
|
+
{ label: "—" },
|
|
320
|
+
{
|
|
321
|
+
action: () => {
|
|
322
|
+
const pp = parentElementPath(path) as JxPath;
|
|
323
|
+
const idx = childIndex(path) as number;
|
|
324
|
+
transactDoc(activeTab.value, (t) =>
|
|
325
|
+
mutateInsertNode(t, pp, idx, { children: [], tagName: "p" }),
|
|
326
|
+
);
|
|
327
|
+
},
|
|
328
|
+
label: "Insert before",
|
|
316
329
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
330
|
+
{
|
|
331
|
+
action: () => {
|
|
332
|
+
const pp = parentElementPath(path) as JxPath;
|
|
333
|
+
const idx = childIndex(path) as number;
|
|
334
|
+
transactDoc(activeTab.value, (t) =>
|
|
335
|
+
mutateInsertNode(t, pp, idx + 1, { children: [], tagName: "p" }),
|
|
336
|
+
);
|
|
337
|
+
},
|
|
338
|
+
label: "Insert after",
|
|
326
339
|
},
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (!(path.length >= 2 && path.at(-2) === "children" && path.at(-1) === "map")) {
|
|
340
|
+
{
|
|
341
|
+
action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
|
|
342
|
+
label: "Wrap in Div",
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
// Don't offer Repeat on a repeater template (path tail "map") or on an array node itself.
|
|
346
|
+
if (path.at(-1) !== "map" && (node as JxMutableNode).$prototype !== "Array") {
|
|
335
347
|
items.push({
|
|
336
348
|
action: () => convertToRepeater(),
|
|
337
349
|
label: "Repeat...",
|
|
@@ -368,48 +380,56 @@ export function showContextMenu(
|
|
|
368
380
|
});
|
|
369
381
|
}
|
|
370
382
|
}
|
|
371
|
-
|
|
372
|
-
items.push(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
383
|
+
// Separator
|
|
384
|
+
items.push(
|
|
385
|
+
{ label: "—" },
|
|
386
|
+
{
|
|
387
|
+
action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
|
|
388
|
+
danger: true,
|
|
389
|
+
label: "Delete",
|
|
390
|
+
},
|
|
391
|
+
);
|
|
377
392
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
items.push(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
transactDoc(activeTab.value, (t) => {
|
|
388
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
389
|
-
mutateInsertNode(t, path, idx + i, nodes[i]);
|
|
393
|
+
// Paste targets — never into/after an array node (its content is the single map template).
|
|
394
|
+
if (path.length >= 2 && (node as JxMutableNode).$prototype !== "Array") {
|
|
395
|
+
items.push(
|
|
396
|
+
{ label: "—" },
|
|
397
|
+
{
|
|
398
|
+
action: async () => {
|
|
399
|
+
const nodes = await readFromClipboard();
|
|
400
|
+
if (!nodes || nodes.length === 0) {
|
|
401
|
+
return;
|
|
390
402
|
}
|
|
391
|
-
|
|
392
|
-
|
|
403
|
+
const idx = Array.isArray(node.children) ? node.children.length : 0;
|
|
404
|
+
transactDoc(activeTab.value, (t) => {
|
|
405
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
406
|
+
mutateInsertNode(t, path, idx + i, nodes[i]!);
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
statusMessage("Pasted");
|
|
410
|
+
},
|
|
411
|
+
label: "Paste inside",
|
|
393
412
|
},
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
const pp = parentElementPath(path) as JxPath;
|
|
403
|
-
const idx = childIndex(path) as number;
|
|
404
|
-
transactDoc(activeTab.value, (t) => {
|
|
405
|
-
for (let i = 0; i < nodes.length; i++) {
|
|
406
|
-
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
|
|
413
|
+
);
|
|
414
|
+
if (idxIsNumber) {
|
|
415
|
+
items.push({
|
|
416
|
+
action: async () => {
|
|
417
|
+
const nodes = await readFromClipboard();
|
|
418
|
+
if (!nodes || nodes.length === 0) {
|
|
419
|
+
return;
|
|
407
420
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
421
|
+
const pp = parentElementPath(path) as JxPath;
|
|
422
|
+
const idx = childIndex(path) as number;
|
|
423
|
+
transactDoc(activeTab.value, (t) => {
|
|
424
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
425
|
+
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
statusMessage("Pasted");
|
|
429
|
+
},
|
|
430
|
+
label: "Paste after",
|
|
431
|
+
});
|
|
432
|
+
}
|
|
413
433
|
}
|
|
414
434
|
|
|
415
435
|
let x = e.clientX,
|
|
@@ -445,7 +465,7 @@ export function showContextMenu(
|
|
|
445
465
|
style=${item.danger ? "color: var(--danger)" : ""}
|
|
446
466
|
@click=${() => {
|
|
447
467
|
dismissContextMenu();
|
|
448
|
-
item.action?.();
|
|
468
|
+
void item.action?.();
|
|
449
469
|
}}
|
|
450
470
|
>${item.label}</sp-menu-item
|
|
451
471
|
>`,
|
|
@@ -8,8 +8,10 @@ import { activeTab } from "../workspace/workspace";
|
|
|
8
8
|
import { transact } from "../tabs/transact";
|
|
9
9
|
import { componentRegistry, computeRelativePath, loadComponentRegistry } from "../files/components";
|
|
10
10
|
import { getPlatform } from "../platform";
|
|
11
|
+
import { jsonClone } from "../utils/studio-utils";
|
|
11
12
|
import { statusMessage } from "../panels/statusbar";
|
|
12
13
|
import { showDialog } from "../ui/layers";
|
|
14
|
+
import { validateComponentSlots } from "../services/cem-export";
|
|
13
15
|
|
|
14
16
|
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
15
17
|
|
|
@@ -75,7 +77,12 @@ export async function convertToComponent() {
|
|
|
75
77
|
const platform = getPlatform();
|
|
76
78
|
await platform.writeFile(componentFile, JSON.stringify(componentDef, null, 2));
|
|
77
79
|
await loadComponentRegistry();
|
|
78
|
-
|
|
80
|
+
const warning = validateComponentSlots(componentDef);
|
|
81
|
+
if (warning) {
|
|
82
|
+
statusMessage(`Converted to <${name}> — Warning: ${warning}`, 6000);
|
|
83
|
+
} else {
|
|
84
|
+
statusMessage(`Converted to <${name}>`);
|
|
85
|
+
}
|
|
79
86
|
} catch (error) {
|
|
80
87
|
statusMessage(`Error saving component: ${errorMessage(error)}`);
|
|
81
88
|
}
|
|
@@ -102,7 +109,9 @@ function deriveDefaultName(node: JxMutableNode) {
|
|
|
102
109
|
* @returns {JxMutableNode}
|
|
103
110
|
*/
|
|
104
111
|
function extractComponentDef(node: JxMutableNode) {
|
|
105
|
-
|
|
112
|
+
// JsonClone, not structuredClone: the selected node is a @vue/reactivity proxy,
|
|
113
|
+
// Which structuredClone rejects with DataCloneError.
|
|
114
|
+
const clone = jsonClone(node);
|
|
106
115
|
delete clone.$id;
|
|
107
116
|
delete clone.$layout;
|
|
108
117
|
delete clone.$paths;
|
|
@@ -68,10 +68,9 @@ export async function convertToRepeater() {
|
|
|
68
68
|
repeater.sort = config.sort;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
};
|
|
71
|
+
// Replace the selected element in place with the array pseudo-element — no wrapper div. The
|
|
72
|
+
// Repeated items render directly among the original parent's children.
|
|
73
|
+
(parent.children as (string | JxMutableNode)[])[idx] = repeater as unknown as JxMutableNode;
|
|
75
74
|
});
|
|
76
75
|
}
|
|
77
76
|
|
|
@@ -111,7 +110,7 @@ async function promptRepeaterConfig(defs: Record<string, unknown>) {
|
|
|
111
110
|
|
|
112
111
|
const fnDefs = Object.entries(defs).filter(([, d]) => defCategory(d) === "function");
|
|
113
112
|
|
|
114
|
-
let source = arrayDefs.length > 0 ? arrayDefs[0][0] : "__new__";
|
|
113
|
+
let source = arrayDefs.length > 0 ? arrayDefs[0]![0] : "__new__";
|
|
115
114
|
let newDefName = "";
|
|
116
115
|
let filterDef = "";
|
|
117
116
|
let sortDef = "";
|
|
@@ -522,8 +522,8 @@ function elementToJx(el: HTMLElement): JxContentResult {
|
|
|
522
522
|
if (nodes.length === 0) {
|
|
523
523
|
return { textContent: "" };
|
|
524
524
|
}
|
|
525
|
-
if (nodes.length === 1 && nodes[0]
|
|
526
|
-
return { textContent: nodes[0]
|
|
525
|
+
if (nodes.length === 1 && nodes[0]!.nodeType === Node.TEXT_NODE) {
|
|
526
|
+
return { textContent: nodes[0]!.textContent };
|
|
527
527
|
}
|
|
528
528
|
|
|
529
529
|
// Mixed content → children array
|
|
@@ -589,8 +589,8 @@ function domNodeToJx(node: Node) {
|
|
|
589
589
|
const { childNodes } = el;
|
|
590
590
|
if (childNodes.length === 0) {
|
|
591
591
|
result.textContent = "";
|
|
592
|
-
} else if (childNodes.length === 1 && childNodes[0]
|
|
593
|
-
result.textContent = childNodes[0]
|
|
592
|
+
} else if (childNodes.length === 1 && childNodes[0]!.nodeType === Node.TEXT_NODE) {
|
|
593
|
+
result.textContent = childNodes[0]!.textContent;
|
|
594
594
|
} else {
|
|
595
595
|
result.children = [];
|
|
596
596
|
for (const child of childNodes) {
|
|
@@ -615,8 +615,8 @@ function fragmentToJx(frag: DocumentFragment) {
|
|
|
615
615
|
if (nodes.length === 0) {
|
|
616
616
|
return { textContent: "" };
|
|
617
617
|
}
|
|
618
|
-
if (nodes.length === 1 && nodes[0]
|
|
619
|
-
return { textContent: nodes[0]
|
|
618
|
+
if (nodes.length === 1 && nodes[0]!.nodeType === Node.TEXT_NODE) {
|
|
619
|
+
return { textContent: nodes[0]!.textContent };
|
|
620
620
|
}
|
|
621
621
|
|
|
622
622
|
const children: (JxMutableNode | string)[] = [];
|
|
@@ -630,10 +630,10 @@ function fragmentToJx(frag: DocumentFragment) {
|
|
|
630
630
|
if (
|
|
631
631
|
children.length === 1 &&
|
|
632
632
|
typeof children[0] !== "string" &&
|
|
633
|
-
children[0]
|
|
634
|
-
typeof children[0]
|
|
633
|
+
children[0]!.tagName === "span" &&
|
|
634
|
+
typeof children[0]!.textContent === "string"
|
|
635
635
|
) {
|
|
636
|
-
return { textContent: children[0]
|
|
636
|
+
return { textContent: children[0]!.textContent };
|
|
637
637
|
}
|
|
638
638
|
|
|
639
639
|
return children.length > 0 ? { children } : { textContent: "" };
|
|
@@ -149,14 +149,14 @@ function findIntersectingElements(tag: string, range: Range, root: HTMLElement)
|
|
|
149
149
|
* @param {Element[]} matches
|
|
150
150
|
*/
|
|
151
151
|
function unwrapTagInRange(
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
_tag: string,
|
|
153
|
+
_range: Range,
|
|
154
|
+
_editableRoot: HTMLElement,
|
|
155
155
|
matches: Element[],
|
|
156
156
|
) {
|
|
157
157
|
// Process in reverse so DOM mutations don't shift later nodes
|
|
158
158
|
for (let i = matches.length - 1; i >= 0; i--) {
|
|
159
|
-
const el = matches[i]
|
|
159
|
+
const el = matches[i]!;
|
|
160
160
|
unwrapElement(el);
|
|
161
161
|
}
|
|
162
162
|
}
|
|
@@ -249,7 +249,7 @@ function trimLeadingWhitespace(frag: DocumentFragment) {
|
|
|
249
249
|
if (!match) {
|
|
250
250
|
return null;
|
|
251
251
|
}
|
|
252
|
-
const
|
|
252
|
+
const ws = match[1]!;
|
|
253
253
|
if (text.length === ws.length) {
|
|
254
254
|
// Entire node is whitespace — remove it
|
|
255
255
|
first.remove();
|
|
@@ -276,7 +276,7 @@ function trimTrailingWhitespace(frag: DocumentFragment) {
|
|
|
276
276
|
if (!match) {
|
|
277
277
|
return null;
|
|
278
278
|
}
|
|
279
|
-
const
|
|
279
|
+
const ws = match[1]!;
|
|
280
280
|
if (text.length === ws.length) {
|
|
281
281
|
last.remove();
|
|
282
282
|
} else {
|
|
@@ -363,7 +363,7 @@ function mergeAdjacentSiblings(root: HTMLElement) {
|
|
|
363
363
|
|
|
364
364
|
// Process in reverse to preserve earlier offsets
|
|
365
365
|
for (let i = toMerge.length - 1; i >= 0; i--) {
|
|
366
|
-
const [el, next] = toMerge[i]
|
|
366
|
+
const [el, next] = toMerge[i]!;
|
|
367
367
|
// Move all children from next into el
|
|
368
368
|
while (next.firstChild) {
|
|
369
369
|
el.append(next.firstChild);
|
|
@@ -468,8 +468,8 @@ function liftEdgeWhitespace(root: HTMLElement) {
|
|
|
468
468
|
if (first && first.nodeType === Node.TEXT_NODE) {
|
|
469
469
|
const text = first.textContent ?? "";
|
|
470
470
|
const m = text.match(/^(\s+)/);
|
|
471
|
-
if (m && text.length > m[1]
|
|
472
|
-
ops.push({ el, type: "lift-leading", ws: m[1] });
|
|
471
|
+
if (m && text.length > m[1]!.length) {
|
|
472
|
+
ops.push({ el, type: "lift-leading", ws: m[1]! });
|
|
473
473
|
}
|
|
474
474
|
}
|
|
475
475
|
|
|
@@ -477,8 +477,8 @@ function liftEdgeWhitespace(root: HTMLElement) {
|
|
|
477
477
|
if (last && last.nodeType === Node.TEXT_NODE && last !== first) {
|
|
478
478
|
const text = last.textContent ?? "";
|
|
479
479
|
const m = text.match(/(\s+)$/);
|
|
480
|
-
if (m && text.length > m[1]
|
|
481
|
-
ops.push({ el, type: "lift-trailing", ws: m[1] });
|
|
480
|
+
if (m && text.length > m[1]!.length) {
|
|
481
|
+
ops.push({ el, type: "lift-trailing", ws: m[1]! });
|
|
482
482
|
}
|
|
483
483
|
}
|
|
484
484
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merge tags — enumerate insertable `${…}` template tokens from the document's state.
|
|
3
|
+
*
|
|
4
|
+
* Powers the inline-edit "Insert data" menu (Mailchimp-style merge tags). Given the document's
|
|
5
|
+
* state definitions plus the live resolved scope, produces a flat list of accessor paths the author
|
|
6
|
+
* can drop into editable text: top-level `state.*` names, nested object paths, array `.length`, and
|
|
7
|
+
* — when editing inside a repeater — `item`, `item.*`, and `index`.
|
|
8
|
+
*
|
|
9
|
+
* Pure and DOM-free: callers pass the data in, this returns plain descriptors.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { defCategory } from "../panels/signals-panel";
|
|
13
|
+
import { dataTypeLabel, unwrapSignal } from "../panels/data-explorer";
|
|
14
|
+
|
|
15
|
+
export interface MergeTag {
|
|
16
|
+
/** Insertion token placed between `${` and `}`, e.g. `state.user.name`, `item.title`, `index`. */
|
|
17
|
+
token: string;
|
|
18
|
+
/** Display path shown in the menu (currently identical to `token`). */
|
|
19
|
+
label: string;
|
|
20
|
+
/** Type + short value preview, e.g. `string · "Alice"`, `Array(4)`, `{3}`. */
|
|
21
|
+
hint: string;
|
|
22
|
+
/** Source category — `defCategory` of the root def, or `"repeater"` for item/index entries. */
|
|
23
|
+
category: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Levels of nesting walked beneath each root name. */
|
|
27
|
+
const DEPTH_CAP = 3;
|
|
28
|
+
/** Maximum object keys enumerated per level (mirrors the data-explorer tree cap). */
|
|
29
|
+
const BREADTH_CAP = 30;
|
|
30
|
+
|
|
31
|
+
/** Type label plus a short value preview for primitives. */
|
|
32
|
+
function previewHint(value: unknown): string {
|
|
33
|
+
const v = unwrapSignal(value);
|
|
34
|
+
const typeLabel = dataTypeLabel(v);
|
|
35
|
+
if (v === null || v === undefined || typeof v === "object") {
|
|
36
|
+
return typeLabel; // Null / pending / Array(n) / {n}
|
|
37
|
+
}
|
|
38
|
+
const text =
|
|
39
|
+
typeof v === "string" ? (v.length > 24 ? `"${v.slice(0, 24)}…"` : `"${v}"`) : String(v);
|
|
40
|
+
return `${typeLabel} · ${text}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk a resolved value, appending nested-path tags to `out`. Arrays contribute a `.length` tag and
|
|
45
|
+
* stop (index access is context-dependent); plain objects recurse up to the depth/breadth caps.
|
|
46
|
+
*/
|
|
47
|
+
function walk(value: unknown, prefix: string, category: string, depth: number, out: MergeTag[]) {
|
|
48
|
+
if (depth >= DEPTH_CAP) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const v = unwrapSignal(value);
|
|
52
|
+
if (v === null || v === undefined || typeof v !== "object") {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(v)) {
|
|
56
|
+
const token = `${prefix}.length`;
|
|
57
|
+
out.push({ category, hint: `number · ${v.length}`, label: token, token });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const keys = Object.keys(v).slice(0, BREADTH_CAP);
|
|
61
|
+
for (const key of keys) {
|
|
62
|
+
if (key.startsWith("$")) {
|
|
63
|
+
continue; // Internal keys ($children, $ref, reactive plumbing) are not author-facing
|
|
64
|
+
}
|
|
65
|
+
const childVal = (v as Record<string, unknown>)[key];
|
|
66
|
+
const childPrefix = `${prefix}.${key}`;
|
|
67
|
+
out.push({ category, hint: previewHint(childVal), label: childPrefix, token: childPrefix });
|
|
68
|
+
walk(childVal, childPrefix, category, depth + 1, out);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the list of insertable merge tags for the current editing context.
|
|
74
|
+
*
|
|
75
|
+
* @param state - The document's `state` definitions (`tab.doc.document.state`), used for names and
|
|
76
|
+
* classification (functions are skipped — they are not text-insertable values).
|
|
77
|
+
* @param scope - The live resolved scope (`panel.liveCtx.scope`) for type/preview hints and the
|
|
78
|
+
* nested-property walk. May be null before the canvas has rendered.
|
|
79
|
+
* @param localScope - The editing element's recorded render scope (`elToScope.get(el)`). When it
|
|
80
|
+
* carries a `$map` (repeater) context, `item` / `item.*` / `index` tags are appended.
|
|
81
|
+
*/
|
|
82
|
+
export function buildMergeTags(
|
|
83
|
+
state: Record<string, unknown> | null | undefined,
|
|
84
|
+
scope: Record<string, unknown> | null | undefined,
|
|
85
|
+
localScope: Record<string, unknown> | null = null,
|
|
86
|
+
): MergeTag[] {
|
|
87
|
+
const out: MergeTag[] = [];
|
|
88
|
+
const defs = state ?? {};
|
|
89
|
+
const scp = scope ?? {};
|
|
90
|
+
|
|
91
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
92
|
+
if (name.startsWith("$")) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const category = defCategory(def);
|
|
96
|
+
if (category === "function") {
|
|
97
|
+
continue; // Handlers/functions aren't insertable as text values
|
|
98
|
+
}
|
|
99
|
+
const value = scp[name];
|
|
100
|
+
const token = `state.${name}`;
|
|
101
|
+
out.push({ category, hint: previewHint(value), label: token, token });
|
|
102
|
+
walk(value, token, category, 0, out);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (localScope) {
|
|
106
|
+
const map = unwrapSignal(localScope.$map) as { item?: unknown; index?: unknown } | undefined;
|
|
107
|
+
if (map && typeof map === "object") {
|
|
108
|
+
out.push({ category: "repeater", hint: previewHint(map.item), label: "item", token: "item" });
|
|
109
|
+
walk(map.item, "item", "repeater", 0, out);
|
|
110
|
+
out.push({
|
|
111
|
+
category: "repeater",
|
|
112
|
+
hint: previewHint(map.index),
|
|
113
|
+
label: "index",
|
|
114
|
+
token: "index",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return out;
|
|
120
|
+
}
|
package/src/editor/shortcuts.ts
CHANGED
|
@@ -183,7 +183,7 @@ export function initShortcuts(
|
|
|
183
183
|
const tabToClose = workspace.tabs.get(workspace.activeTabId);
|
|
184
184
|
if (tabToClose?.doc.dirty) {
|
|
185
185
|
const name = tabToClose.documentPath?.split("/").pop() || "Untitled";
|
|
186
|
-
showConfirmDialog(
|
|
186
|
+
void showConfirmDialog(
|
|
187
187
|
"Unsaved Changes",
|
|
188
188
|
`"${name}" has unsaved changes. Close without saving?`,
|
|
189
189
|
{ confirmLabel: "Close", destructive: true },
|
|
@@ -232,17 +232,17 @@ export function initShortcuts(
|
|
|
232
232
|
}
|
|
233
233
|
case "c": {
|
|
234
234
|
e.preventDefault();
|
|
235
|
-
copyNode();
|
|
235
|
+
void copyNode();
|
|
236
236
|
break;
|
|
237
237
|
}
|
|
238
238
|
case "x": {
|
|
239
239
|
e.preventDefault();
|
|
240
|
-
cutNode();
|
|
240
|
+
void cutNode();
|
|
241
241
|
break;
|
|
242
242
|
}
|
|
243
243
|
case "v": {
|
|
244
244
|
e.preventDefault();
|
|
245
|
-
pasteNode();
|
|
245
|
+
void pasteNode();
|
|
246
246
|
break;
|
|
247
247
|
}
|
|
248
248
|
case "0": {
|
package/src/files/components.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { getPlatform } from "../platform";
|
|
4
4
|
import { projectState } from "../store";
|
|
5
5
|
import type { ComponentMeta } from "../types";
|
|
6
|
+
import type { JxMutableNode } from "@jxsuite/schema/types";
|
|
6
7
|
|
|
7
8
|
/** A discovered component: project components carry a file path; package components may not. */
|
|
8
9
|
export interface ComponentEntry extends Omit<ComponentMeta, "path"> {
|
|
@@ -25,6 +26,42 @@ export async function loadComponentRegistry() {
|
|
|
25
26
|
}
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Build a fresh instance definition for a component: $props from prop defaults, plus the
|
|
31
|
+
* component's slot fallback children cloned in as starting content so editors can edit from the
|
|
32
|
+
* defaults. Children destined for a named slot carry a `slot` attribute (required for runtime
|
|
33
|
+
* distribution); fallback for the default slot is inserted as-is. When the component has no slot
|
|
34
|
+
* fallback, no `children` key is emitted so slotless instances stay atomic in the layers tree.
|
|
35
|
+
*
|
|
36
|
+
* @param {ComponentEntry} comp
|
|
37
|
+
* @returns {JxMutableNode}
|
|
38
|
+
*/
|
|
39
|
+
export function buildComponentInstance(comp: ComponentEntry): JxMutableNode {
|
|
40
|
+
const $props = Object.fromEntries(
|
|
41
|
+
(comp.props ?? []).map((p) => [p.name, p.default !== undefined ? p.default : ""]),
|
|
42
|
+
);
|
|
43
|
+
const children: (JxMutableNode | string)[] = [];
|
|
44
|
+
for (const slot of comp.slots ?? []) {
|
|
45
|
+
for (const child of structuredClone(slot.fallback ?? [])) {
|
|
46
|
+
if (!slot.name) {
|
|
47
|
+
children.push(child);
|
|
48
|
+
} else if (typeof child === "object" && child !== null && typeof child.tagName === "string") {
|
|
49
|
+
// Preserve an author-set slot attribute (slot forwarding inside fallback).
|
|
50
|
+
child.attributes = { slot: slot.name, ...child.attributes };
|
|
51
|
+
children.push(child);
|
|
52
|
+
} else {
|
|
53
|
+
// Text and non-element nodes can't carry a slot attribute — wrap them.
|
|
54
|
+
children.push({ attributes: { slot: slot.name }, children: [child], tagName: "span" });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
$props,
|
|
60
|
+
tagName: comp.tagName,
|
|
61
|
+
...(children.length > 0 ? { children } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
28
65
|
/**
|
|
29
66
|
* @param {string | null} fromDocPath
|
|
30
67
|
* @param {string} toCompPath
|