@jxsuite/studio 0.20.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.js +1229 -477
- package/dist/studio.js.map +41 -39
- package/package.json +6 -5
- package/src/browse/browse-modal.js +16 -13
- package/src/browse/browse.js +19 -16
- package/src/canvas/canvas-live-render.js +19 -0
- package/src/canvas/canvas-utils.js +13 -6
- package/src/editor/context-menu.js +27 -13
- package/src/editor/convert-targets.js +60 -0
- package/src/editor/convert-to-component.js +9 -10
- package/src/editor/convert-to-repeater.js +226 -0
- package/src/editor/inline-edit.js +3 -0
- package/src/editor/shortcuts.js +10 -2
- package/src/editor/slash-menu.js +36 -17
- package/src/files/files.js +244 -16
- package/src/github/github-publish.js +26 -15
- package/src/panels/activity-bar.js +5 -5
- package/src/panels/ai-panel.js +10 -3
- package/src/panels/block-action-bar.js +73 -24
- package/src/panels/git-panel.js +7 -2
- package/src/panels/imports-panel.js +6 -1
- package/src/panels/left-panel.js +20 -1
- package/src/panels/properties-panel.js +16 -11
- package/src/panels/quick-search.js +4 -4
- package/src/panels/right-panel.js +16 -14
- package/src/panels/signals-panel.js +120 -0
- package/src/panels/statusbar.js +6 -2
- package/src/panels/style-panel.js +6 -2
- package/src/panels/tab-strip.js +5 -2
- package/src/settings/content-types-editor.js +1 -1
- package/src/settings/settings-modal.js +12 -9
- package/src/store.js +15 -9
- package/src/studio.js +17 -9
- package/src/ui/layers.js +31 -1
- package/src/utils/edit-display.js +1 -6
- package/src/utils/studio-utils.js +11 -11
- package/src/workspace/workspace.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Jx Studio — visual builder for Jx documents",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -24,15 +24,16 @@
|
|
|
24
24
|
"scripts": {
|
|
25
25
|
"build": "bun build ./src/studio.js --outdir dist --target browser --sourcemap=linked && bun build ./node_modules/monaco-editor/esm/vs/editor/editor.worker.js --outdir dist/workers --target browser --entry-naming editor.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/json/json.worker.js --outdir dist/workers --target browser --entry-naming json.worker.js && bun build ./node_modules/monaco-editor/esm/vs/language/typescript/ts.worker.js --outdir dist/workers --target browser --entry-naming ts.worker.js",
|
|
26
26
|
"gen:webdata": "bun run scripts/gen-webdata.js",
|
|
27
|
-
"test": "bun test",
|
|
27
|
+
"test": "bun test --isolate",
|
|
28
|
+
"test:coverage": "bun test --isolate --coverage",
|
|
28
29
|
"upgrade": "bunx npm-check-updates -u && bun install"
|
|
29
30
|
},
|
|
30
31
|
"dependencies": {
|
|
31
32
|
"@atlaskit/pragmatic-drag-and-drop": "^1.8.1",
|
|
32
33
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.1.0",
|
|
33
|
-
"@jxsuite/parser": "^0.
|
|
34
|
-
"@jxsuite/runtime": "^0.
|
|
35
|
-
"@jxsuite/schema": "^0.
|
|
34
|
+
"@jxsuite/parser": "^0.21.0",
|
|
35
|
+
"@jxsuite/runtime": "^0.21.0",
|
|
36
|
+
"@jxsuite/schema": "^0.21.0",
|
|
36
37
|
"@spectrum-web-components/accordion": "^1.12.1",
|
|
37
38
|
"@spectrum-web-components/action-bar": "1.12.1",
|
|
38
39
|
"@spectrum-web-components/action-button": "^1.12.1",
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { html } from "lit-html";
|
|
7
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
7
8
|
import { renderBrowse } from "./browse.js";
|
|
8
9
|
import { openFileInTab } from "../files/files.js";
|
|
9
10
|
import { openModal } from "../ui/layers.js";
|
|
@@ -31,23 +32,25 @@ export function openBrowseModal() {
|
|
|
31
32
|
<sp-icon-close slot="icon"></sp-icon-close>
|
|
32
33
|
</sp-action-button>
|
|
33
34
|
</div>
|
|
34
|
-
<div
|
|
35
|
+
<div
|
|
36
|
+
class="browse-modal-content"
|
|
37
|
+
${ref((el) => {
|
|
38
|
+
if (el) {
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
renderBrowse(/** @type {HTMLElement} */ (el), {
|
|
41
|
+
openFile: (/** @type {string} */ path) => {
|
|
42
|
+
closeBrowseModal();
|
|
43
|
+
openFileInTab(path);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
})}
|
|
49
|
+
></div>
|
|
35
50
|
</div>
|
|
36
51
|
`;
|
|
37
52
|
|
|
38
53
|
_handle = openModal(tpl);
|
|
39
|
-
|
|
40
|
-
requestAnimationFrame(() => {
|
|
41
|
-
const container = _handle?.host.querySelector(".browse-modal-content");
|
|
42
|
-
if (container) {
|
|
43
|
-
renderBrowse(/** @type {HTMLElement} */ (container), {
|
|
44
|
-
openFile: (/** @type {string} */ path) => {
|
|
45
|
-
closeBrowseModal();
|
|
46
|
-
openFileInTab(path);
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
export function closeBrowseModal() {
|
package/src/browse/browse.js
CHANGED
|
@@ -154,7 +154,7 @@ function contentTypeFor(filePath) {
|
|
|
154
154
|
for (const [name, def] of Object.entries(config.contentTypes)) {
|
|
155
155
|
const d = /** @type {ContentTypeDef} */ (def);
|
|
156
156
|
if (!d.source) continue;
|
|
157
|
-
const prefix = d.source.replace(/^\.\//, "").
|
|
157
|
+
const prefix = d.source.replace(/^\.\//, "").replace(/\/$/, "");
|
|
158
158
|
if (filePath.startsWith(prefix + "/") || filePath === prefix) {
|
|
159
159
|
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
160
160
|
}
|
|
@@ -233,12 +233,12 @@ function getContentTypeTypes() {
|
|
|
233
233
|
if (!config?.contentTypes) return [];
|
|
234
234
|
return Object.entries(config.contentTypes).map(([name, def]) => {
|
|
235
235
|
const d = /** @type {ContentTypeDef} */ (def);
|
|
236
|
-
const dir = d.source ? d.source.replace(/^\.\//, "").
|
|
236
|
+
const dir = d.source ? d.source.replace(/^\.\//, "").replace(/\/$/, "") : name;
|
|
237
237
|
return {
|
|
238
238
|
key: `contentType:${name}`,
|
|
239
239
|
label: name.charAt(0).toUpperCase() + name.slice(1),
|
|
240
240
|
dir,
|
|
241
|
-
ext: "
|
|
241
|
+
ext: `.${d.format || "md"}`,
|
|
242
242
|
contentTypeName: name,
|
|
243
243
|
};
|
|
244
244
|
});
|
|
@@ -362,7 +362,22 @@ function showBrowseContextMenu(e, file, container, ctx) {
|
|
|
362
362
|
y = e.clientY;
|
|
363
363
|
|
|
364
364
|
_browseCtxHandle = renderPopover(
|
|
365
|
-
html`<sp-popover
|
|
365
|
+
html`<sp-popover
|
|
366
|
+
open
|
|
367
|
+
style="position:fixed;left:${x}px;top:${y}px"
|
|
368
|
+
${ref((el) => {
|
|
369
|
+
if (!el) return;
|
|
370
|
+
requestAnimationFrame(() => {
|
|
371
|
+
const popover = /** @type {HTMLElement} */ (el);
|
|
372
|
+
const menuRect = popover.getBoundingClientRect();
|
|
373
|
+
if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
|
|
374
|
+
if (y + menuRect.height > window.innerHeight)
|
|
375
|
+
y = window.innerHeight - menuRect.height - 4;
|
|
376
|
+
popover.style.left = `${x}px`;
|
|
377
|
+
popover.style.top = `${y}px`;
|
|
378
|
+
});
|
|
379
|
+
})}
|
|
380
|
+
>
|
|
366
381
|
<sp-menu>
|
|
367
382
|
${items.map((item) =>
|
|
368
383
|
item.label === "\u2014"
|
|
@@ -386,18 +401,6 @@ function showBrowseContextMenu(e, file, container, ctx) {
|
|
|
386
401
|
layer: "dialog",
|
|
387
402
|
},
|
|
388
403
|
);
|
|
389
|
-
|
|
390
|
-
requestAnimationFrame(() => {
|
|
391
|
-
const popover = /** @type {HTMLElement | null} */ (
|
|
392
|
-
_browseCtxHandle?.host.querySelector("sp-popover")
|
|
393
|
-
);
|
|
394
|
-
if (!popover) return;
|
|
395
|
-
const menuRect = popover.getBoundingClientRect();
|
|
396
|
-
if (x + menuRect.width > window.innerWidth) x = window.innerWidth - menuRect.width - 4;
|
|
397
|
-
if (y + menuRect.height > window.innerHeight) y = window.innerHeight - menuRect.height - 4;
|
|
398
|
-
popover.style.left = `${x}px`;
|
|
399
|
-
popover.style.top = `${y}px`;
|
|
400
|
-
});
|
|
401
404
|
}
|
|
402
405
|
|
|
403
406
|
/**
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
buildScope,
|
|
14
14
|
defineElement,
|
|
15
15
|
setSkipServerFunctions,
|
|
16
|
+
setSkipContentResolution,
|
|
16
17
|
} from "@jxsuite/runtime";
|
|
17
18
|
import {
|
|
18
19
|
getEffectiveElements,
|
|
@@ -30,6 +31,16 @@ import { buildNestedSiteCSS } from "./nested-site-style.js";
|
|
|
30
31
|
|
|
31
32
|
export { buildNestedSiteCSS } from "./nested-site-style.js";
|
|
32
33
|
|
|
34
|
+
/** @param {Event} e */
|
|
35
|
+
function _preventNav(e) {
|
|
36
|
+
if (/** @type {HTMLElement} */ (e.target).closest("a[href]")) {
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Canvas elements that already have the delegated nav guard listener. */
|
|
42
|
+
const _navGuarded = new WeakSet();
|
|
43
|
+
|
|
33
44
|
/** @type {{ getCanvasMode: () => string } | null} */
|
|
34
45
|
let _ctx = null;
|
|
35
46
|
|
|
@@ -128,6 +139,7 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
|
|
|
128
139
|
// failed proxy calls and infinite reactive retries (also covers
|
|
129
140
|
// async custom element connectedCallbacks that run after this function returns)
|
|
130
141
|
setSkipServerFunctions(canvasMode !== "preview");
|
|
142
|
+
setSkipContentResolution(canvasMode !== "preview");
|
|
131
143
|
|
|
132
144
|
let renderDoc =
|
|
133
145
|
canvasMode === "preview"
|
|
@@ -460,6 +472,13 @@ export async function renderCanvasLive(gen, doc, canvasEl) {
|
|
|
460
472
|
|
|
461
473
|
canvasEl.appendChild(el);
|
|
462
474
|
|
|
475
|
+
// Delegated click handler prevents link navigation in all canvas modes.
|
|
476
|
+
// Attached once per canvasEl (survives reactive re-renders that replace children).
|
|
477
|
+
if (!_navGuarded.has(canvasEl)) {
|
|
478
|
+
canvasEl.addEventListener("click", _preventNav);
|
|
479
|
+
_navGuarded.add(canvasEl);
|
|
480
|
+
}
|
|
481
|
+
|
|
463
482
|
if (canvasMode === "design" || canvasMode === "edit") {
|
|
464
483
|
requestAnimationFrame(() => {
|
|
465
484
|
const editingEl = getActiveElement();
|
|
@@ -25,6 +25,9 @@ import { getActivePanel, findCanvasElement } from "./canvas-helpers.js";
|
|
|
25
25
|
*/
|
|
26
26
|
let _ctx;
|
|
27
27
|
|
|
28
|
+
/** @type {HTMLElement | null} */
|
|
29
|
+
let _zoomIndicatorEl = null;
|
|
30
|
+
|
|
28
31
|
/**
|
|
29
32
|
* Initialize the canvas utils module.
|
|
30
33
|
*
|
|
@@ -288,7 +291,12 @@ export function renderZoomIndicator() {
|
|
|
288
291
|
const host = getLayerSlot("popover", "zoom-indicator");
|
|
289
292
|
litRender(
|
|
290
293
|
html`
|
|
291
|
-
<div
|
|
294
|
+
<div
|
|
295
|
+
class="zoom-indicator"
|
|
296
|
+
${ref((el) => {
|
|
297
|
+
_zoomIndicatorEl = /** @type {HTMLElement | null} */ (el || null);
|
|
298
|
+
})}
|
|
299
|
+
>
|
|
292
300
|
<span class="zoom-indicator-action" title="Reset to 100%" @click=${resetZoom}>
|
|
293
301
|
<svg
|
|
294
302
|
width="14"
|
|
@@ -325,12 +333,11 @@ export function renderZoomIndicator() {
|
|
|
325
333
|
|
|
326
334
|
/** Position the zoom indicator relative to canvas-wrap bounds. */
|
|
327
335
|
export function positionZoomIndicator() {
|
|
328
|
-
|
|
329
|
-
if (!indicator) return;
|
|
336
|
+
if (!_zoomIndicatorEl) return;
|
|
330
337
|
const rect = canvasWrap.getBoundingClientRect();
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
338
|
+
_zoomIndicatorEl.style.left = `${rect.left + rect.width / 2}px`;
|
|
339
|
+
_zoomIndicatorEl.style.top = `${rect.bottom - 32}px`;
|
|
340
|
+
_zoomIndicatorEl.style.transform = "translateX(-50%)";
|
|
334
341
|
}
|
|
335
342
|
|
|
336
343
|
/** Toggle "active" class on canvas panel headers based on activeMedia. */
|
|
@@ -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";
|
|
@@ -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";
|
|
@@ -172,6 +173,14 @@ function promptComponentName(defaultName) {
|
|
|
172
173
|
?negative=${!!error}
|
|
173
174
|
@input=${onInput}
|
|
174
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
|
+
})}
|
|
175
184
|
>
|
|
176
185
|
<sp-help-text slot="negative-help-text">${error}</sp-help-text>
|
|
177
186
|
</sp-textfield>
|
|
@@ -179,16 +188,6 @@ function promptComponentName(defaultName) {
|
|
|
179
188
|
`;
|
|
180
189
|
}
|
|
181
190
|
|
|
182
|
-
requestAnimationFrame(() => {
|
|
183
|
-
const layer = document.getElementById("layer-dialog");
|
|
184
|
-
const tf = /** @type {HTMLElement | null} */ (layer?.querySelector("sp-textfield"));
|
|
185
|
-
if (tf) {
|
|
186
|
-
tf.focus();
|
|
187
|
-
const input = tf.shadowRoot?.querySelector("input");
|
|
188
|
-
if (input) input.select();
|
|
189
|
-
}
|
|
190
|
-
});
|
|
191
|
-
|
|
192
191
|
return buildTpl();
|
|
193
192
|
});
|
|
194
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
|
+
}
|
package/src/editor/shortcuts.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { isEditing, stopEditing } from "./inline-edit.js";
|
|
19
19
|
import { copyNode, cutNode, pasteNode } from "./context-menu.js";
|
|
20
20
|
import { openQuickSearch } from "../panels/quick-search.js";
|
|
21
|
+
import { showConfirmDialog } from "../ui/layers.js";
|
|
21
22
|
|
|
22
23
|
/** @typedef {import("../state.js").JxPath} JxPath */
|
|
23
24
|
|
|
@@ -160,9 +161,16 @@ export function initShortcuts(getContext) {
|
|
|
160
161
|
const tab = workspace.tabs.get(workspace.activeTabId);
|
|
161
162
|
if (tab?.doc.dirty) {
|
|
162
163
|
const name = tab.documentPath?.split("/").pop() || "Untitled";
|
|
163
|
-
|
|
164
|
+
showConfirmDialog(
|
|
165
|
+
"Unsaved Changes",
|
|
166
|
+
`"${name}" has unsaved changes. Close without saving?`,
|
|
167
|
+
{ confirmLabel: "Close", destructive: true },
|
|
168
|
+
).then((confirmed) => {
|
|
169
|
+
if (confirmed && workspace.activeTabId) closeTab(workspace.activeTabId);
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
closeTab(workspace.activeTabId);
|
|
164
173
|
}
|
|
165
|
-
closeTab(workspace.activeTabId);
|
|
166
174
|
}
|
|
167
175
|
break;
|
|
168
176
|
case "o":
|