@jxsuite/studio 0.19.0 → 0.21.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.css +98 -98
- package/dist/studio.js +13476 -12330
- package/dist/studio.js.map +152 -148
- 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 +19 -7
- package/src/browse/browse-modal.js +16 -13
- package/src/browse/browse.js +44 -35
- package/src/canvas/canvas-diff.js +24 -16
- package/src/canvas/canvas-helpers.js +24 -19
- package/src/canvas/canvas-live-render.js +113 -39
- package/src/canvas/canvas-render.js +119 -73
- package/src/canvas/canvas-utils.js +51 -21
- package/src/canvas/nested-site-style.js +50 -0
- package/src/editor/component-inline-edit.js +14 -13
- package/src/editor/content-inline-edit.js +22 -10
- package/src/editor/context-menu.js +28 -14
- package/src/editor/convert-targets.js +60 -0
- package/src/editor/convert-to-component.js +20 -19
- package/src/editor/convert-to-repeater.js +226 -0
- package/src/editor/inline-edit.js +69 -28
- package/src/editor/insertion-helper.js +16 -12
- package/src/editor/shortcuts.js +14 -4
- package/src/editor/slash-menu.js +40 -19
- package/src/files/components.js +1 -1
- package/src/files/file-ops.js +37 -16
- package/src/files/files.js +322 -68
- package/src/github/github-auth.js +122 -0
- package/src/github/github-publish.js +126 -0
- package/src/markdown/md-convert.js +172 -107
- package/src/new-project/new-project-modal.js +204 -0
- package/src/panels/activity-bar.js +27 -25
- package/src/panels/ai-panel.js +406 -0
- package/src/panels/block-action-bar.js +77 -28
- package/src/panels/canvas-dnd.js +8 -2
- package/src/panels/data-explorer.js +19 -13
- package/src/panels/dnd.js +144 -92
- package/src/panels/editors.js +55 -35
- package/src/panels/elements-panel.js +33 -19
- package/src/panels/events-panel.js +15 -10
- package/src/panels/git-panel.js +475 -99
- package/src/panels/head-panel.js +121 -81
- package/src/panels/imports-panel.js +94 -65
- package/src/panels/layers-panel.js +64 -43
- package/src/panels/left-panel.js +105 -48
- package/src/panels/overlays.js +1 -0
- package/src/panels/panel-events.js +45 -30
- package/src/panels/preview-render.js +23 -18
- package/src/panels/properties-panel.js +378 -257
- package/src/panels/pseudo-preview.js +2 -2
- package/src/panels/quick-search.js +11 -9
- package/src/panels/right-panel.js +42 -16
- package/src/panels/shared.js +4 -4
- package/src/panels/signals-panel.js +415 -166
- package/src/panels/statusbar.js +10 -6
- package/src/panels/style-inputs.js +67 -35
- package/src/panels/style-panel.js +239 -134
- package/src/panels/style-utils.js +34 -16
- package/src/panels/stylebook-layers-panel.js +12 -10
- package/src/panels/stylebook-panel.js +134 -66
- package/src/panels/tab-strip.js +5 -2
- package/src/panels/toolbar.js +333 -109
- package/src/panels/welcome-screen.js +121 -0
- package/src/platform.js +2 -4
- package/src/platforms/devserver.js +113 -7
- package/src/resize-edges.js +98 -0
- package/src/services/cem-export.js +12 -7
- package/src/services/code-services.js +30 -12
- package/src/settings/content-types-editor.js +8 -8
- package/src/settings/css-vars-editor.js +30 -24
- package/src/settings/defs-editor.js +12 -7
- package/src/settings/general-settings.js +85 -3
- package/src/settings/head-editor.js +30 -24
- package/src/settings/schema-field-ui.js +58 -39
- package/src/settings/settings-modal.js +12 -9
- package/src/site-context.js +41 -31
- package/src/state.js +70 -29
- package/src/store.js +36 -35
- package/src/studio.js +120 -90
- package/src/tabs/tab.js +67 -43
- package/src/tabs/transact.js +23 -13
- package/src/ui/button-group.js +24 -24
- package/src/ui/color-selector.js +28 -23
- package/src/ui/field-row.js +3 -3
- package/src/ui/icons.js +2 -2
- package/src/ui/layers.js +31 -1
- package/src/ui/media-picker.js +7 -7
- package/src/ui/spectrum.js +5 -1
- package/src/ui/unit-selector.js +18 -16
- package/src/ui/value-selector.js +21 -15
- package/src/ui/widgets.js +22 -19
- package/src/utils/canvas-media.js +5 -4
- package/src/utils/edit-display.js +32 -26
- package/src/utils/google-fonts.js +11 -11
- package/src/utils/inherited-style.js +9 -8
- package/src/utils/studio-utils.js +18 -18
- package/src/view.js +58 -1
- package/src/workspace/workspace.js +29 -7
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Content inline edit bridge — extracted from studio.js (Phase 4k). Rich-text editing entry point
|
|
3
3
|
* for edit/content mode. Bridges startEditing() with Jx document state mutations.
|
|
4
|
+
*
|
|
5
|
+
* @typedef {import("./inline-edit.js").JxContentResult} JxContentResult
|
|
6
|
+
*
|
|
7
|
+
* @typedef {import("./inline-edit.js").SlashCommand} SlashCommand
|
|
4
8
|
*/
|
|
5
9
|
|
|
6
10
|
import {
|
|
@@ -22,8 +26,8 @@ import { findCanvasElement, getActivePanel } from "../canvas/canvas-helpers.js";
|
|
|
22
26
|
/**
|
|
23
27
|
* Enter rich-text inline editing on a canvas element (edit/content mode).
|
|
24
28
|
*
|
|
25
|
-
* @param {
|
|
26
|
-
* @param {
|
|
29
|
+
* @param {HTMLElement} el
|
|
30
|
+
* @param {JxPath} path
|
|
27
31
|
*/
|
|
28
32
|
export function enterInlineEdit(el, path) {
|
|
29
33
|
// Restore raw template expressions before editing.
|
|
@@ -39,9 +43,9 @@ export function enterInlineEdit(el, path) {
|
|
|
39
43
|
|
|
40
44
|
startEditing(el, path, {
|
|
41
45
|
onCommit(
|
|
42
|
-
/** @type {
|
|
43
|
-
/** @type {
|
|
44
|
-
/** @type {
|
|
46
|
+
/** @type {JxPath} */ commitPath,
|
|
47
|
+
/** @type {(JxMutableNode | string)[] | null} */ children,
|
|
48
|
+
/** @type {string | null} */ textContent,
|
|
45
49
|
) {
|
|
46
50
|
const node = getNodeAtPath(activeTab.value?.doc.document, commitPath);
|
|
47
51
|
if (children) {
|
|
@@ -59,13 +63,17 @@ export function enterInlineEdit(el, path) {
|
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
65
|
|
|
62
|
-
onSplit(
|
|
66
|
+
onSplit(
|
|
67
|
+
/** @type {JxPath} */ splitPath,
|
|
68
|
+
/** @type {JxContentResult} */ before,
|
|
69
|
+
/** @type {JxContentResult} */ after,
|
|
70
|
+
) {
|
|
63
71
|
const tag = "p";
|
|
64
72
|
|
|
65
73
|
// Insert new element after with "after" content
|
|
66
|
-
const parentPath = /** @type {
|
|
74
|
+
const parentPath = /** @type {JxPath} */ (parentElementPath(splitPath));
|
|
67
75
|
const idx = /** @type {number} */ (childIndex(splitPath));
|
|
68
|
-
/** @type {
|
|
76
|
+
/** @type {JxMutableNode} */
|
|
69
77
|
const newNode = { tagName: tag };
|
|
70
78
|
if (after.textContent != null) {
|
|
71
79
|
newNode.textContent = after.textContent;
|
|
@@ -108,7 +116,11 @@ export function enterInlineEdit(el, path) {
|
|
|
108
116
|
});
|
|
109
117
|
},
|
|
110
118
|
|
|
111
|
-
onInsert(
|
|
119
|
+
onInsert(
|
|
120
|
+
/** @type {JxPath} */ afterPath,
|
|
121
|
+
/** @type {SlashCommand} */ cmd,
|
|
122
|
+
/** @type {JxContentResult | undefined} */ commitData,
|
|
123
|
+
) {
|
|
112
124
|
const isEmpty =
|
|
113
125
|
!commitData ||
|
|
114
126
|
(commitData.textContent != null && commitData.textContent.trim() === "") ||
|
|
@@ -148,7 +160,7 @@ export function enterInlineEdit(el, path) {
|
|
|
148
160
|
}
|
|
149
161
|
|
|
150
162
|
const elementDef = defaultDef(cmd.tag);
|
|
151
|
-
const parentPath = /** @type {
|
|
163
|
+
const parentPath = /** @type {JxPath} */ (parentElementPath(afterPath));
|
|
152
164
|
const idx = /** @type {number} */ (childIndex(afterPath));
|
|
153
165
|
const newPath = [...parentPath, "children", idx + 1];
|
|
154
166
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ─── Clipboard & Context Menu ─────────────────────────────────────────────────
|
|
2
2
|
import { html } from "lit-html";
|
|
3
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
3
4
|
import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
|
|
4
5
|
import { activeTab, workspace } from "../workspace/workspace.js";
|
|
5
6
|
import {
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
} from "../tabs/transact.js";
|
|
13
14
|
import { statusMessage } from "../panels/statusbar.js";
|
|
14
15
|
import { convertToComponent } from "./convert-to-component.js";
|
|
16
|
+
import { convertToRepeater } from "./convert-to-repeater.js";
|
|
15
17
|
import { componentRegistry } from "../files/components.js";
|
|
16
18
|
import { renderPopover } from "../ui/layers.js";
|
|
17
19
|
import { startLayerTitleEdit } from "../panels/layers-panel.js";
|
|
@@ -21,7 +23,7 @@ import { startLayerTitleEdit } from "../panels/layers-panel.js";
|
|
|
21
23
|
*
|
|
22
24
|
* @typedef {import("../state.js").JxPath} JxPath
|
|
23
25
|
*
|
|
24
|
-
* @typedef {
|
|
26
|
+
* @typedef {JxMutableNode} JxNode
|
|
25
27
|
*/
|
|
26
28
|
|
|
27
29
|
// ─── Clipboard ────────────────────────────────────────────────────────────────
|
|
@@ -169,6 +171,15 @@ export function showContextMenu(e, path, opts = {}) {
|
|
|
169
171
|
label: "Wrap in Div",
|
|
170
172
|
action: () => transactDoc(activeTab.value, (t) => mutateWrapNode(t, path)),
|
|
171
173
|
});
|
|
174
|
+
// Don't show Repeat if already inside a repeater (path ends with "children", "map")
|
|
175
|
+
if (
|
|
176
|
+
!(path.length >= 2 && path[path.length - 2] === "children" && path[path.length - 1] === "map")
|
|
177
|
+
) {
|
|
178
|
+
items.push({
|
|
179
|
+
label: "Repeat...",
|
|
180
|
+
action: () => convertToRepeater(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
172
183
|
items.push({
|
|
173
184
|
label: "Set Title",
|
|
174
185
|
action: () => {
|
|
@@ -231,7 +242,22 @@ export function showContextMenu(e, path, opts = {}) {
|
|
|
231
242
|
y = e.clientY;
|
|
232
243
|
|
|
233
244
|
_ctxHandle = renderPopover(
|
|
234
|
-
html`<sp-popover
|
|
245
|
+
html`<sp-popover
|
|
246
|
+
open
|
|
247
|
+
style="position:fixed;z-index:10000;left:${x}px;top:${y}px"
|
|
248
|
+
${ref((el) => {
|
|
249
|
+
if (!el) return;
|
|
250
|
+
requestAnimationFrame(() => {
|
|
251
|
+
const popover = /** @type {HTMLElement} */ (el);
|
|
252
|
+
const menuRect = popover.getBoundingClientRect();
|
|
253
|
+
if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
|
|
254
|
+
if (y + menuRect.height > window.innerHeight)
|
|
255
|
+
y = window.innerHeight - menuRect.height - 4;
|
|
256
|
+
popover.style.left = `${x}px`;
|
|
257
|
+
popover.style.top = `${y}px`;
|
|
258
|
+
});
|
|
259
|
+
})}
|
|
260
|
+
>
|
|
235
261
|
<sp-menu>
|
|
236
262
|
${items.map((item) =>
|
|
237
263
|
item.label === "—"
|
|
@@ -254,16 +280,4 @@ export function showContextMenu(e, path, opts = {}) {
|
|
|
254
280
|
},
|
|
255
281
|
},
|
|
256
282
|
);
|
|
257
|
-
|
|
258
|
-
requestAnimationFrame(() => {
|
|
259
|
-
const popover = /** @type {HTMLElement | null} */ (
|
|
260
|
-
_ctxHandle?.host.querySelector("sp-popover")
|
|
261
|
-
);
|
|
262
|
-
if (!popover) return;
|
|
263
|
-
const menuRect = popover.getBoundingClientRect();
|
|
264
|
-
if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
|
|
265
|
-
if (y + menuRect.height > window.innerHeight) y = window.innerHeight - menuRect.height - 4;
|
|
266
|
-
popover.style.left = `${x}px`;
|
|
267
|
-
popover.style.top = `${y}px`;
|
|
268
|
-
});
|
|
269
283
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import elementsMeta from "../../data/elements-meta.json";
|
|
2
|
+
|
|
3
|
+
/** @typedef {{ label: string; tag: string; description: string }} SlashCommand */
|
|
4
|
+
|
|
5
|
+
const TAG_LABELS = /** @type {Record<string, string>} */ ({
|
|
6
|
+
p: "Paragraph",
|
|
7
|
+
h1: "Heading 1",
|
|
8
|
+
h2: "Heading 2",
|
|
9
|
+
h3: "Heading 3",
|
|
10
|
+
h4: "Heading 4",
|
|
11
|
+
h5: "Heading 5",
|
|
12
|
+
h6: "Heading 6",
|
|
13
|
+
blockquote: "Blockquote",
|
|
14
|
+
div: "Div",
|
|
15
|
+
section: "Section",
|
|
16
|
+
article: "Article",
|
|
17
|
+
aside: "Aside",
|
|
18
|
+
main: "Main",
|
|
19
|
+
header: "Header",
|
|
20
|
+
footer: "Footer",
|
|
21
|
+
nav: "Nav",
|
|
22
|
+
search: "Search",
|
|
23
|
+
ul: "Bulleted List",
|
|
24
|
+
ol: "Numbered List",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const groups = /** @type {Record<string, string[]>} */ (elementsMeta.$convertGroups || {});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get the list of tags the current element can be converted to.
|
|
31
|
+
*
|
|
32
|
+
* @param {string} currentTag
|
|
33
|
+
* @param {boolean} isEmpty
|
|
34
|
+
* @returns {SlashCommand[]}
|
|
35
|
+
*/
|
|
36
|
+
export function getConvertTargets(currentTag, isEmpty) {
|
|
37
|
+
const def = /** @type {Record<string, unknown> | undefined} */ (
|
|
38
|
+
/** @type {Record<string, Record<string, unknown>>} */ (elementsMeta.$defs)?.[currentTag]
|
|
39
|
+
);
|
|
40
|
+
if (!def?.$convertTo) return [];
|
|
41
|
+
|
|
42
|
+
const groupNames =
|
|
43
|
+
isEmpty && def.$convertToWhenEmpty
|
|
44
|
+
? /** @type {string[]} */ (def.$convertToWhenEmpty)
|
|
45
|
+
: [/** @type {string} */ (def.$convertTo)];
|
|
46
|
+
|
|
47
|
+
/** @type {Set<string>} */
|
|
48
|
+
const tags = new Set();
|
|
49
|
+
for (const name of groupNames) {
|
|
50
|
+
for (const tag of groups[name] || []) {
|
|
51
|
+
if (tag !== currentTag) tags.add(tag);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return [...tags].map((tag) => ({
|
|
56
|
+
label: TAG_LABELS[tag] || tag.charAt(0).toUpperCase() + tag.slice(1),
|
|
57
|
+
tag,
|
|
58
|
+
description: "",
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// ─── Convert to Component ─────────────────────────────────────────────────────
|
|
2
2
|
import { html, render as litRender } from "lit-html";
|
|
3
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
3
4
|
import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
|
|
4
5
|
import { activeTab } from "../workspace/workspace.js";
|
|
5
6
|
import { transact } from "../tabs/transact.js";
|
|
@@ -39,15 +40,17 @@ export async function convertToComponent() {
|
|
|
39
40
|
transact(tab, (doc) => {
|
|
40
41
|
// Navigate to parent's children array and replace the node
|
|
41
42
|
const pp = parentElementPath(selectionPath) ?? [];
|
|
42
|
-
const idx = childIndex(selectionPath);
|
|
43
|
+
const idx = /** @type {number} */ (childIndex(selectionPath));
|
|
43
44
|
let parent = doc;
|
|
44
45
|
for (const seg of pp) parent = parent[seg];
|
|
46
|
+
if (!parent.children) parent.children = [];
|
|
45
47
|
parent.children[idx] = { tagName: name };
|
|
46
48
|
|
|
47
49
|
// Ensure $elements exists and add the $ref
|
|
48
50
|
if (!doc.$elements) doc.$elements = [];
|
|
49
51
|
const alreadyReferenced = doc.$elements.some(
|
|
50
|
-
(/** @type {
|
|
52
|
+
(/** @type {JxMutableNode | string | { $ref: string }} */ el) =>
|
|
53
|
+
el && typeof el === "object" && "$ref" in el && el.$ref === refPath,
|
|
51
54
|
);
|
|
52
55
|
if (!alreadyReferenced) {
|
|
53
56
|
doc.$elements.push({ $ref: refPath });
|
|
@@ -60,15 +63,15 @@ export async function convertToComponent() {
|
|
|
60
63
|
await platform.writeFile(componentFile, JSON.stringify(componentDef, null, 2));
|
|
61
64
|
await loadComponentRegistry();
|
|
62
65
|
statusMessage(`Converted to <${name}>`);
|
|
63
|
-
} catch (/** @type {
|
|
64
|
-
statusMessage(`Error saving component: ${err.message}`);
|
|
66
|
+
} catch (/** @type {unknown} */ err) {
|
|
67
|
+
statusMessage(`Error saving component: ${/** @type {Error} */ (err).message}`);
|
|
65
68
|
}
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
72
|
* Derive a default tag name from a node.
|
|
70
73
|
*
|
|
71
|
-
* @param {
|
|
74
|
+
* @param {JxMutableNode} node
|
|
72
75
|
* @returns {string}
|
|
73
76
|
*/
|
|
74
77
|
function deriveDefaultName(node) {
|
|
@@ -80,8 +83,8 @@ function deriveDefaultName(node) {
|
|
|
80
83
|
/**
|
|
81
84
|
* Deep clone a node and strip page-specific keys.
|
|
82
85
|
*
|
|
83
|
-
* @param {
|
|
84
|
-
* @returns {
|
|
86
|
+
* @param {JxMutableNode} node
|
|
87
|
+
* @returns {JxMutableNode}
|
|
85
88
|
*/
|
|
86
89
|
function extractComponentDef(node) {
|
|
87
90
|
const clone = structuredClone(node);
|
|
@@ -105,7 +108,7 @@ function validateName(val) {
|
|
|
105
108
|
if (!VALID_NAME.test(val)) {
|
|
106
109
|
return { valid: false, error: "Lowercase letters, digits, and hyphens only" };
|
|
107
110
|
}
|
|
108
|
-
const exists = componentRegistry.some((/** @type {
|
|
111
|
+
const exists = componentRegistry.some((/** @type {JxMutableNode} */ c) => c.tagName === val);
|
|
109
112
|
if (exists) {
|
|
110
113
|
return { valid: false, error: `Component <${val}> already exists` };
|
|
111
114
|
}
|
|
@@ -134,7 +137,7 @@ function promptComponentName(defaultName) {
|
|
|
134
137
|
}
|
|
135
138
|
|
|
136
139
|
function onInput(/** @type {Event} */ e) {
|
|
137
|
-
value = /** @type {
|
|
140
|
+
value = /** @type {HTMLInputElement} */ (e.target).value || "";
|
|
138
141
|
const result = validateName(value);
|
|
139
142
|
error = result.valid ? "" : result.error;
|
|
140
143
|
rerender();
|
|
@@ -170,6 +173,14 @@ function promptComponentName(defaultName) {
|
|
|
170
173
|
?negative=${!!error}
|
|
171
174
|
@input=${onInput}
|
|
172
175
|
@keydown=${onKeydown}
|
|
176
|
+
${ref((el) => {
|
|
177
|
+
if (el)
|
|
178
|
+
requestAnimationFrame(() => {
|
|
179
|
+
/** @type {HTMLElement} */ (el).focus();
|
|
180
|
+
const input = /** @type {HTMLElement} */ (el).shadowRoot?.querySelector("input");
|
|
181
|
+
if (input) input.select();
|
|
182
|
+
});
|
|
183
|
+
})}
|
|
173
184
|
>
|
|
174
185
|
<sp-help-text slot="negative-help-text">${error}</sp-help-text>
|
|
175
186
|
</sp-textfield>
|
|
@@ -177,16 +188,6 @@ function promptComponentName(defaultName) {
|
|
|
177
188
|
`;
|
|
178
189
|
}
|
|
179
190
|
|
|
180
|
-
requestAnimationFrame(() => {
|
|
181
|
-
const layer = document.getElementById("layer-dialog");
|
|
182
|
-
const tf = /** @type {any} */ (layer?.querySelector("sp-textfield"));
|
|
183
|
-
if (tf) {
|
|
184
|
-
tf.focus();
|
|
185
|
-
const input = tf.shadowRoot?.querySelector("input");
|
|
186
|
-
if (input) input.select();
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
|
|
190
191
|
return buildTpl();
|
|
191
192
|
});
|
|
192
193
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// ─── Convert to Repeater ──────────────────────────────────────────────────────
|
|
2
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
3
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
4
|
+
import { getNodeAtPath, parentElementPath, childIndex } from "../store.js";
|
|
5
|
+
import { activeTab } from "../workspace/workspace.js";
|
|
6
|
+
import { transactDoc } from "../tabs/transact.js";
|
|
7
|
+
import { showDialog } from "../ui/layers.js";
|
|
8
|
+
import { defCategory } from "../panels/signals-panel.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{
|
|
12
|
+
* items: { $ref: string } | unknown[];
|
|
13
|
+
* filter?: { $ref: string };
|
|
14
|
+
* sort?: { $ref: string };
|
|
15
|
+
* newDef?: { name: string };
|
|
16
|
+
* }} RepeaterConfig
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Convert the currently selected element into a repeater template. */
|
|
20
|
+
export async function convertToRepeater() {
|
|
21
|
+
const tab = activeTab.value;
|
|
22
|
+
if (!tab?.session.selection || tab.session.selection.length < 2) return;
|
|
23
|
+
|
|
24
|
+
const path = tab.session.selection;
|
|
25
|
+
const node = getNodeAtPath(tab.doc.document, path);
|
|
26
|
+
if (!node) return;
|
|
27
|
+
|
|
28
|
+
const defs = tab.doc.document.state || {};
|
|
29
|
+
const config = await promptRepeaterConfig(defs);
|
|
30
|
+
if (!config) return;
|
|
31
|
+
|
|
32
|
+
transactDoc(tab, (t) => {
|
|
33
|
+
const doc = t.doc.document;
|
|
34
|
+
if (config.newDef) {
|
|
35
|
+
if (!doc.state) doc.state = {};
|
|
36
|
+
doc.state[config.newDef.name] = { type: "array", default: [] };
|
|
37
|
+
}
|
|
38
|
+
const pp = parentElementPath(path);
|
|
39
|
+
if (!pp) return;
|
|
40
|
+
const idx = /** @type {number} */ (childIndex(path));
|
|
41
|
+
const parent = getNodeAtPath(doc, pp);
|
|
42
|
+
if (!parent?.children) return;
|
|
43
|
+
const element = parent.children[idx];
|
|
44
|
+
|
|
45
|
+
/** @type {Record<string, unknown>} */
|
|
46
|
+
const repeater = {
|
|
47
|
+
$prototype: "Array",
|
|
48
|
+
items: config.items,
|
|
49
|
+
map: element,
|
|
50
|
+
};
|
|
51
|
+
if (config.filter) repeater.filter = config.filter;
|
|
52
|
+
if (config.sort) repeater.sort = config.sort;
|
|
53
|
+
|
|
54
|
+
/** @type {(string | JxMutableNode)[]} */ (parent.children)[idx] = {
|
|
55
|
+
tagName: "div",
|
|
56
|
+
children: /** @type {any} */ (repeater),
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {Record<string, unknown>} defs
|
|
63
|
+
* @returns {Promise<RepeaterConfig | null>}
|
|
64
|
+
*/
|
|
65
|
+
function promptRepeaterConfig(defs) {
|
|
66
|
+
const arrayDefs = Object.entries(defs).filter(
|
|
67
|
+
([, d]) =>
|
|
68
|
+
/** @type {any} */ (d)?.type === "array" ||
|
|
69
|
+
Array.isArray(/** @type {any} */ (d)?.default) ||
|
|
70
|
+
/** @type {any} */ (d)?.$prototype === "Array",
|
|
71
|
+
);
|
|
72
|
+
const fnDefs = Object.entries(defs).filter(([, d]) => defCategory(d) === "function");
|
|
73
|
+
|
|
74
|
+
let source = arrayDefs.length > 0 ? arrayDefs[0][0] : "__new__";
|
|
75
|
+
let newDefName = "";
|
|
76
|
+
let filterDef = "";
|
|
77
|
+
let sortDef = "";
|
|
78
|
+
let error = "";
|
|
79
|
+
|
|
80
|
+
return showDialog((done) => {
|
|
81
|
+
function confirm() {
|
|
82
|
+
if (source === "__new__") {
|
|
83
|
+
const name = newDefName.trim();
|
|
84
|
+
if (!name) {
|
|
85
|
+
error = "Enter a name for the new state definition.";
|
|
86
|
+
rerender();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (defs[name]) {
|
|
90
|
+
error = `"${name}" already exists.`;
|
|
91
|
+
rerender();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name)) {
|
|
95
|
+
error = "Invalid identifier name.";
|
|
96
|
+
rerender();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
done({
|
|
100
|
+
items: { $ref: `#/$defs/${name}` },
|
|
101
|
+
filter: filterDef ? { $ref: `#/$defs/${filterDef}` } : undefined,
|
|
102
|
+
sort: sortDef ? { $ref: `#/$defs/${sortDef}` } : undefined,
|
|
103
|
+
newDef: { name },
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
done({
|
|
107
|
+
items: { $ref: `#/$defs/${source}` },
|
|
108
|
+
filter: filterDef ? { $ref: `#/$defs/${filterDef}` } : undefined,
|
|
109
|
+
sort: sortDef ? { $ref: `#/$defs/${sortDef}` } : undefined,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rerender() {
|
|
115
|
+
const layer = document.getElementById("layer-dialog");
|
|
116
|
+
const slot = layer?.lastElementChild;
|
|
117
|
+
if (slot) litRender(buildTpl(), /** @type {HTMLElement} */ (slot));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function buildTpl() {
|
|
121
|
+
return html`
|
|
122
|
+
<sp-dialog-wrapper
|
|
123
|
+
open
|
|
124
|
+
underlay
|
|
125
|
+
headline="Repeat..."
|
|
126
|
+
confirm-label="Create Repeater"
|
|
127
|
+
cancel-label="Cancel"
|
|
128
|
+
size="s"
|
|
129
|
+
@confirm=${confirm}
|
|
130
|
+
@cancel=${() => done(null)}
|
|
131
|
+
@close=${() => done(null)}
|
|
132
|
+
>
|
|
133
|
+
<div style="display:flex;flex-direction:column;gap:12px">
|
|
134
|
+
<label>
|
|
135
|
+
<sp-field-label size="s">Items source</sp-field-label>
|
|
136
|
+
<sp-picker
|
|
137
|
+
label="Items source"
|
|
138
|
+
size="s"
|
|
139
|
+
.value=${source}
|
|
140
|
+
@change=${(/** @type {Event} */ e) => {
|
|
141
|
+
source = /** @type {HTMLInputElement} */ (e.target).value;
|
|
142
|
+
error = "";
|
|
143
|
+
rerender();
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
${arrayDefs.map(
|
|
147
|
+
([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
|
|
148
|
+
)}
|
|
149
|
+
<sp-menu-divider></sp-menu-divider>
|
|
150
|
+
<sp-menu-item value="__new__">Create new...</sp-menu-item>
|
|
151
|
+
</sp-picker>
|
|
152
|
+
</label>
|
|
153
|
+
|
|
154
|
+
${source === "__new__"
|
|
155
|
+
? html`
|
|
156
|
+
<label>
|
|
157
|
+
<sp-field-label size="s">New definition name</sp-field-label>
|
|
158
|
+
<sp-textfield
|
|
159
|
+
size="s"
|
|
160
|
+
placeholder="myItems"
|
|
161
|
+
.value=${newDefName}
|
|
162
|
+
?negative=${!!error}
|
|
163
|
+
@input=${(/** @type {Event} */ e) => {
|
|
164
|
+
newDefName = /** @type {HTMLInputElement} */ (e.target).value || "";
|
|
165
|
+
error = "";
|
|
166
|
+
rerender();
|
|
167
|
+
}}
|
|
168
|
+
@keydown=${(/** @type {KeyboardEvent} */ e) => {
|
|
169
|
+
if (e.key === "Enter") confirm();
|
|
170
|
+
}}
|
|
171
|
+
${ref((el) => {
|
|
172
|
+
if (el && source === "__new__")
|
|
173
|
+
requestAnimationFrame(() => /** @type {HTMLElement} */ (el).focus());
|
|
174
|
+
})}
|
|
175
|
+
>
|
|
176
|
+
<sp-help-text slot="negative-help-text">${error}</sp-help-text>
|
|
177
|
+
</sp-textfield>
|
|
178
|
+
</label>
|
|
179
|
+
`
|
|
180
|
+
: nothing}
|
|
181
|
+
${fnDefs.length > 0
|
|
182
|
+
? html`
|
|
183
|
+
<label>
|
|
184
|
+
<sp-field-label size="s">Filter (optional)</sp-field-label>
|
|
185
|
+
<sp-picker
|
|
186
|
+
label="Filter"
|
|
187
|
+
size="s"
|
|
188
|
+
.value=${filterDef}
|
|
189
|
+
@change=${(/** @type {Event} */ e) => {
|
|
190
|
+
filterDef = /** @type {HTMLInputElement} */ (e.target).value;
|
|
191
|
+
rerender();
|
|
192
|
+
}}
|
|
193
|
+
>
|
|
194
|
+
<sp-menu-item value="">None</sp-menu-item>
|
|
195
|
+
${fnDefs.map(
|
|
196
|
+
([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
|
|
197
|
+
)}
|
|
198
|
+
</sp-picker>
|
|
199
|
+
</label>
|
|
200
|
+
<label>
|
|
201
|
+
<sp-field-label size="s">Sort (optional)</sp-field-label>
|
|
202
|
+
<sp-picker
|
|
203
|
+
label="Sort"
|
|
204
|
+
size="s"
|
|
205
|
+
.value=${sortDef}
|
|
206
|
+
@change=${(/** @type {Event} */ e) => {
|
|
207
|
+
sortDef = /** @type {HTMLInputElement} */ (e.target).value;
|
|
208
|
+
rerender();
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
<sp-menu-item value="">None</sp-menu-item>
|
|
212
|
+
${fnDefs.map(
|
|
213
|
+
([name]) => html`<sp-menu-item value=${name}>${name}</sp-menu-item>`,
|
|
214
|
+
)}
|
|
215
|
+
</sp-picker>
|
|
216
|
+
</label>
|
|
217
|
+
`
|
|
218
|
+
: nothing}
|
|
219
|
+
</div>
|
|
220
|
+
</sp-dialog-wrapper>
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return buildTpl();
|
|
225
|
+
});
|
|
226
|
+
}
|