@jxsuite/studio 0.28.5 → 0.29.0
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 +6769 -5399
- package/dist/studio.js.map +86 -79
- package/package.json +5 -4
- 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 +92 -74
- 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 +25 -21
- 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 +12 -15
- 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 +24 -22
- 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 +60 -34
- 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 +73 -33
- 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 +41 -0
- 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 +6 -1
- package/src/ui/unit-selector.ts +2 -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
|
@@ -97,7 +97,7 @@ async function readFromClipboard() {
|
|
|
97
97
|
for (const item of items) {
|
|
98
98
|
if (item.types.includes(JX_MIME)) {
|
|
99
99
|
const blob = await item.getType(JX_MIME);
|
|
100
|
-
const json = JSON.parse(await blob.text());
|
|
100
|
+
const json = JSON.parse(await blob.text()) as JxNode;
|
|
101
101
|
return [json];
|
|
102
102
|
}
|
|
103
103
|
if (item.types.includes("text/html")) {
|
|
@@ -116,7 +116,7 @@ async function readFromClipboard() {
|
|
|
116
116
|
const text = await blob.text();
|
|
117
117
|
// Try parsing as Jx JSON
|
|
118
118
|
try {
|
|
119
|
-
const parsed = JSON.parse(text);
|
|
119
|
+
const parsed = JSON.parse(text) as JxNode;
|
|
120
120
|
if (parsed && parsed.tagName) {
|
|
121
121
|
return [parsed];
|
|
122
122
|
}
|
|
@@ -191,14 +191,14 @@ export async function pasteNode() {
|
|
|
191
191
|
const idx = childIndex(tab.session.selection) as number;
|
|
192
192
|
transactDoc(tab, (t) => {
|
|
193
193
|
for (let i = 0; i < nodes.length; i++) {
|
|
194
|
-
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]);
|
|
194
|
+
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
|
|
195
195
|
}
|
|
196
196
|
});
|
|
197
197
|
} else {
|
|
198
198
|
const idx = Array.isArray(parent.children) ? parent.children.length : 0;
|
|
199
199
|
transactDoc(tab, (t) => {
|
|
200
200
|
for (let i = 0; i < nodes.length; i++) {
|
|
201
|
-
mutateInsertNode(t, pPath, idx + i, nodes[i]);
|
|
201
|
+
mutateInsertNode(t, pPath, idx + i, nodes[i]!);
|
|
202
202
|
}
|
|
203
203
|
});
|
|
204
204
|
}
|
|
@@ -272,16 +272,23 @@ export function showContextMenu(
|
|
|
272
272
|
// Select the node
|
|
273
273
|
tab.session.selection = path;
|
|
274
274
|
|
|
275
|
+
// Index-based structural actions (cut/duplicate/insert/wrap/delete) require a numeric child
|
|
276
|
+
// Index. Repeater templates (path tail "map") and the document root don't have one — they get
|
|
277
|
+
// Copy only — so we never splice with a non-numeric index.
|
|
278
|
+
const idxIsNumber = typeof childIndex(path) === "number";
|
|
279
|
+
|
|
275
280
|
const items: { label: string; action?: () => void | Promise<void>; danger?: boolean }[] = [
|
|
276
281
|
{ action: () => copyNode(), label: "Copy" },
|
|
277
282
|
];
|
|
278
283
|
|
|
279
|
-
if (path.length >= 2) {
|
|
280
|
-
items.push(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
284
|
+
if (path.length >= 2 && idxIsNumber) {
|
|
285
|
+
items.push(
|
|
286
|
+
{ action: () => cutNode(), label: "Cut" },
|
|
287
|
+
{
|
|
288
|
+
action: () => transactDoc(activeTab.value, (t) => mutateDuplicateNode(t, path)),
|
|
289
|
+
label: "Duplicate",
|
|
290
|
+
},
|
|
291
|
+
);
|
|
285
292
|
if (node.style) {
|
|
286
293
|
const nodeStyle = node.style;
|
|
287
294
|
items.push({
|
|
@@ -305,33 +312,36 @@ export function showContextMenu(
|
|
|
305
312
|
label: "Paste styles",
|
|
306
313
|
});
|
|
307
314
|
}
|
|
308
|
-
|
|
309
|
-
items.push(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
315
|
+
// Separator
|
|
316
|
+
items.push(
|
|
317
|
+
{ label: "—" },
|
|
318
|
+
{
|
|
319
|
+
action: () => {
|
|
320
|
+
const pp = parentElementPath(path) as JxPath;
|
|
321
|
+
const idx = childIndex(path) as number;
|
|
322
|
+
transactDoc(activeTab.value, (t) =>
|
|
323
|
+
mutateInsertNode(t, pp, idx, { children: [], tagName: "p" }),
|
|
324
|
+
);
|
|
325
|
+
},
|
|
326
|
+
label: "Insert before",
|
|
316
327
|
},
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
328
|
+
{
|
|
329
|
+
action: () => {
|
|
330
|
+
const pp = parentElementPath(path) as JxPath;
|
|
331
|
+
const idx = childIndex(path) as number;
|
|
332
|
+
transactDoc(activeTab.value, (t) =>
|
|
333
|
+
mutateInsertNode(t, pp, idx + 1, { children: [], tagName: "p" }),
|
|
334
|
+
);
|
|
335
|
+
},
|
|
336
|
+
label: "Insert after",
|
|
326
337
|
},
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if (!(path.length >= 2 && path.at(-2) === "children" && path.at(-1) === "map")) {
|
|
338
|
+
{
|
|
339
|
+
action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
|
|
340
|
+
label: "Wrap in Div",
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
// Don't offer Repeat on a repeater template (path tail "map") or on an array node itself.
|
|
344
|
+
if (path.at(-1) !== "map" && (node as JxMutableNode).$prototype !== "Array") {
|
|
335
345
|
items.push({
|
|
336
346
|
action: () => convertToRepeater(),
|
|
337
347
|
label: "Repeat...",
|
|
@@ -368,48 +378,56 @@ export function showContextMenu(
|
|
|
368
378
|
});
|
|
369
379
|
}
|
|
370
380
|
}
|
|
371
|
-
|
|
372
|
-
items.push(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
381
|
+
// Separator
|
|
382
|
+
items.push(
|
|
383
|
+
{ label: "—" },
|
|
384
|
+
{
|
|
385
|
+
action: () => transactDoc(activeTab.value, (t) => mutateRemoveNode(t, path)),
|
|
386
|
+
danger: true,
|
|
387
|
+
label: "Delete",
|
|
388
|
+
},
|
|
389
|
+
);
|
|
377
390
|
}
|
|
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]);
|
|
391
|
+
// Paste targets — never into/after an array node (its content is the single map template).
|
|
392
|
+
if (path.length >= 2 && (node as JxMutableNode).$prototype !== "Array") {
|
|
393
|
+
items.push(
|
|
394
|
+
{ label: "—" },
|
|
395
|
+
{
|
|
396
|
+
action: async () => {
|
|
397
|
+
const nodes = await readFromClipboard();
|
|
398
|
+
if (!nodes || nodes.length === 0) {
|
|
399
|
+
return;
|
|
390
400
|
}
|
|
391
|
-
|
|
392
|
-
|
|
401
|
+
const idx = Array.isArray(node.children) ? node.children.length : 0;
|
|
402
|
+
transactDoc(activeTab.value, (t) => {
|
|
403
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
404
|
+
mutateInsertNode(t, path, idx + i, nodes[i]!);
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
statusMessage("Pasted");
|
|
408
|
+
},
|
|
409
|
+
label: "Paste inside",
|
|
393
410
|
},
|
|
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]);
|
|
411
|
+
);
|
|
412
|
+
if (idxIsNumber) {
|
|
413
|
+
items.push({
|
|
414
|
+
action: async () => {
|
|
415
|
+
const nodes = await readFromClipboard();
|
|
416
|
+
if (!nodes || nodes.length === 0) {
|
|
417
|
+
return;
|
|
407
418
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
419
|
+
const pp = parentElementPath(path) as JxPath;
|
|
420
|
+
const idx = childIndex(path) as number;
|
|
421
|
+
transactDoc(activeTab.value, (t) => {
|
|
422
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
423
|
+
mutateInsertNode(t, pp, idx + 1 + i, nodes[i]!);
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
statusMessage("Pasted");
|
|
427
|
+
},
|
|
428
|
+
label: "Paste after",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
413
431
|
}
|
|
414
432
|
|
|
415
433
|
let x = e.clientX,
|
|
@@ -445,7 +463,7 @@ export function showContextMenu(
|
|
|
445
463
|
style=${item.danger ? "color: var(--danger)" : ""}
|
|
446
464
|
@click=${() => {
|
|
447
465
|
dismissContextMenu();
|
|
448
|
-
item.action?.();
|
|
466
|
+
void item.action?.();
|
|
449
467
|
}}
|
|
450
468
|
>${item.label}</sp-menu-item
|
|
451
469
|
>`,
|
|
@@ -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
|