@jxsuite/studio 0.0.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 +3676 -0
- package/dist/studio.js +188743 -0
- package/dist/studio.js.map +1448 -0
- package/package.json +67 -0
- package/src/editor/context-menu.js +144 -0
- package/src/editor/inline-edit.js +597 -0
- package/src/editor/inline-format.js +572 -0
- package/src/editor/shortcuts.js +275 -0
- package/src/editor/slash-menu.js +167 -0
- package/src/files/components.js +40 -0
- package/src/files/file-ops.js +195 -0
- package/src/files/files.js +569 -0
- package/src/markdown/md-allowlist.js +101 -0
- package/src/markdown/md-convert.js +491 -0
- package/src/panels/activity-bar.js +69 -0
- package/src/panels/data-explorer.js +181 -0
- package/src/panels/events-panel.js +235 -0
- package/src/panels/imports-panel.js +427 -0
- package/src/panels/signals-panel.js +1093 -0
- package/src/panels/statusbar.js +56 -0
- package/src/platform.js +31 -0
- package/src/platforms/devserver.js +293 -0
- package/src/services/cem-export.js +130 -0
- package/src/services/code-services.js +98 -0
- package/src/site-context.js +122 -0
- package/src/state.js +744 -0
- package/src/store.js +332 -0
- package/src/studio.js +7692 -0
- package/src/ui/icons.js +83 -0
- package/src/ui/jx-styled-combobox.js +142 -0
- package/src/ui/spectrum.js +238 -0
- package/src/utils/studio-utils.js +185 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shortcuts.js — Keyboard shortcuts for Jx Studio
|
|
3
|
+
*
|
|
4
|
+
* Extracted from studio.js. Registers wheel-zoom, middle-mouse pan, resize listener, and keydown
|
|
5
|
+
* shortcuts on the canvas / document.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
selectNode,
|
|
10
|
+
undo,
|
|
11
|
+
redo,
|
|
12
|
+
removeNode,
|
|
13
|
+
insertNode,
|
|
14
|
+
duplicateNode,
|
|
15
|
+
getNodeAtPath,
|
|
16
|
+
parentElementPath,
|
|
17
|
+
childIndex,
|
|
18
|
+
canvasWrap,
|
|
19
|
+
update,
|
|
20
|
+
} from "../store.js";
|
|
21
|
+
import { isEditing } from "./inline-edit.js";
|
|
22
|
+
import { copyNode, cutNode, pasteNode } from "./context-menu.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Initialise all keyboard (and wheel/pointer) shortcuts.
|
|
26
|
+
*
|
|
27
|
+
* @param {() => {
|
|
28
|
+
* S: any;
|
|
29
|
+
* setS: (s: any) => void;
|
|
30
|
+
* canvasMode: string;
|
|
31
|
+
* panX: number;
|
|
32
|
+
* panY: number;
|
|
33
|
+
* setPan: (x: number, y: number) => void;
|
|
34
|
+
* applyTransform: () => void;
|
|
35
|
+
* positionZoomIndicator: () => void;
|
|
36
|
+
* componentInlineEdit: any;
|
|
37
|
+
* saveFile: () => void;
|
|
38
|
+
* openProject: () => void;
|
|
39
|
+
* enterEditOnPath: (path: any) => void;
|
|
40
|
+
* }} getContext
|
|
41
|
+
*/
|
|
42
|
+
export function initShortcuts(getContext) {
|
|
43
|
+
// Wheel handler: Ctrl+Scroll = zoom (cursor-centered), plain scroll = pan
|
|
44
|
+
canvasWrap.addEventListener(
|
|
45
|
+
"wheel",
|
|
46
|
+
(/** @type {any} */ e) => {
|
|
47
|
+
const { S, setS, canvasMode, panX, panY, setPan, applyTransform } = getContext();
|
|
48
|
+
// Edit (content) mode: let the scroll container handle scrolling natively
|
|
49
|
+
if (canvasMode === "edit") return;
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
if (e.ctrlKey || e.metaKey) {
|
|
52
|
+
// Zoom towards cursor
|
|
53
|
+
const rect = canvasWrap.getBoundingClientRect();
|
|
54
|
+
const cursorX = e.clientX - rect.left;
|
|
55
|
+
const cursorY = e.clientY - rect.top;
|
|
56
|
+
const oldZoom = S.ui.zoom;
|
|
57
|
+
const delta = -e.deltaY * 0.005;
|
|
58
|
+
const newZoom = Math.min(5.0, Math.max(0.05, oldZoom * (1 + delta)));
|
|
59
|
+
const ratio = newZoom / oldZoom;
|
|
60
|
+
// Adjust pan so the point under cursor stays stationary
|
|
61
|
+
setPan(cursorX - (cursorX - panX) * ratio, cursorY - (cursorY - panY) * ratio);
|
|
62
|
+
setS({ ...S, ui: { ...S.ui, zoom: newZoom } });
|
|
63
|
+
} else if (e.shiftKey) {
|
|
64
|
+
// Shift+scroll = horizontal pan
|
|
65
|
+
setPan(panX - e.deltaY, panY);
|
|
66
|
+
} else {
|
|
67
|
+
// Pan
|
|
68
|
+
setPan(panX - e.deltaX, panY - e.deltaY);
|
|
69
|
+
}
|
|
70
|
+
applyTransform();
|
|
71
|
+
},
|
|
72
|
+
{ passive: false },
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
// Middle-mouse drag panning
|
|
76
|
+
canvasWrap.addEventListener("pointerdown", (/** @type {any} */ e) => {
|
|
77
|
+
const ctx = getContext();
|
|
78
|
+
if (ctx.canvasMode === "edit") return; // no panning in edit mode
|
|
79
|
+
if (e.button !== 1) return; // middle button only
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
canvasWrap.setPointerCapture(e.pointerId);
|
|
82
|
+
let lastX = e.clientX,
|
|
83
|
+
lastY = e.clientY;
|
|
84
|
+
const onMove = (/** @type {any} */ ev) => {
|
|
85
|
+
const { panX, panY, setPan, applyTransform } = getContext();
|
|
86
|
+
setPan(panX + (ev.clientX - lastX), panY + (ev.clientY - lastY));
|
|
87
|
+
lastX = ev.clientX;
|
|
88
|
+
lastY = ev.clientY;
|
|
89
|
+
applyTransform();
|
|
90
|
+
};
|
|
91
|
+
const onUp = () => {
|
|
92
|
+
canvasWrap.releasePointerCapture(e.pointerId);
|
|
93
|
+
canvasWrap.removeEventListener("pointermove", onMove);
|
|
94
|
+
canvasWrap.removeEventListener("pointerup", onUp);
|
|
95
|
+
};
|
|
96
|
+
canvasWrap.addEventListener("pointermove", onMove);
|
|
97
|
+
canvasWrap.addEventListener("pointerup", onUp);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Reposition zoom indicator on resize
|
|
101
|
+
window.addEventListener("resize", () => getContext().positionZoomIndicator());
|
|
102
|
+
|
|
103
|
+
document.addEventListener("keydown", (e) => {
|
|
104
|
+
const {
|
|
105
|
+
S,
|
|
106
|
+
setS,
|
|
107
|
+
canvasMode,
|
|
108
|
+
setPan,
|
|
109
|
+
applyTransform,
|
|
110
|
+
componentInlineEdit,
|
|
111
|
+
saveFile,
|
|
112
|
+
openProject,
|
|
113
|
+
enterEditOnPath,
|
|
114
|
+
} = getContext();
|
|
115
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
116
|
+
|
|
117
|
+
// Don't intercept when typing in inputs or contenteditable
|
|
118
|
+
if (e.target instanceof HTMLElement && e.target.matches("input, textarea, select")) {
|
|
119
|
+
if (mod && e.key === "s") {
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
saveFile();
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (isEditing()) {
|
|
126
|
+
// Let inline editor handle its own keyboard events; only intercept Save
|
|
127
|
+
if (mod && e.key === "s") {
|
|
128
|
+
e.preventDefault();
|
|
129
|
+
saveFile();
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (componentInlineEdit) {
|
|
134
|
+
if (mod && e.key === "s") {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
saveFile();
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (mod) {
|
|
142
|
+
switch (e.key) {
|
|
143
|
+
case "o":
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
openProject();
|
|
146
|
+
break;
|
|
147
|
+
case "s":
|
|
148
|
+
e.preventDefault();
|
|
149
|
+
saveFile();
|
|
150
|
+
break;
|
|
151
|
+
case "z":
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
update(e.shiftKey ? redo(S) : undo(S));
|
|
154
|
+
break;
|
|
155
|
+
case "d":
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (S.selection) update(duplicateNode(S, S.selection));
|
|
158
|
+
break;
|
|
159
|
+
case "c":
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
copyNode(S);
|
|
162
|
+
break;
|
|
163
|
+
case "x":
|
|
164
|
+
e.preventDefault();
|
|
165
|
+
cutNode(S);
|
|
166
|
+
break;
|
|
167
|
+
case "v":
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
pasteNode(S);
|
|
170
|
+
break;
|
|
171
|
+
case "0":
|
|
172
|
+
if (canvasMode === "edit") break;
|
|
173
|
+
e.preventDefault();
|
|
174
|
+
setS({ ...S, ui: { ...S.ui, zoom: 1 } });
|
|
175
|
+
setPan(16, 16);
|
|
176
|
+
applyTransform();
|
|
177
|
+
break;
|
|
178
|
+
case "=":
|
|
179
|
+
case "+":
|
|
180
|
+
if (canvasMode === "edit") break;
|
|
181
|
+
e.preventDefault();
|
|
182
|
+
setS({ ...S, ui: { ...S.ui, zoom: Math.min(5.0, S.ui.zoom * 1.2) } });
|
|
183
|
+
applyTransform();
|
|
184
|
+
break;
|
|
185
|
+
case "-":
|
|
186
|
+
if (canvasMode === "edit") break;
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setS({ ...S, ui: { ...S.ui, zoom: Math.max(0.05, S.ui.zoom / 1.2) } });
|
|
189
|
+
applyTransform();
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
switch (e.key) {
|
|
196
|
+
case "Delete":
|
|
197
|
+
case "Backspace":
|
|
198
|
+
if (S.selection && S.selection.length >= 2) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
update(removeNode(S, S.selection));
|
|
201
|
+
}
|
|
202
|
+
break;
|
|
203
|
+
case "Escape":
|
|
204
|
+
update(selectNode(S, null));
|
|
205
|
+
break;
|
|
206
|
+
case "Enter":
|
|
207
|
+
if (S.selection && S.selection.length >= 2) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
const pp = /** @type {any} */ (parentElementPath(S.selection));
|
|
210
|
+
const idx = /** @type {number} */ (childIndex(S.selection));
|
|
211
|
+
let s = insertNode(S, pp, idx + 1, { tagName: "p", textContent: "" });
|
|
212
|
+
const newPath = [...pp, "children", idx + 1];
|
|
213
|
+
s = selectNode(s, newPath);
|
|
214
|
+
update(s);
|
|
215
|
+
enterEditOnPath(newPath);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
case "ArrowUp":
|
|
219
|
+
e.preventDefault();
|
|
220
|
+
navigateSelection(S);
|
|
221
|
+
break;
|
|
222
|
+
case "ArrowDown":
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
navigateSelection(S, 1);
|
|
225
|
+
break;
|
|
226
|
+
case "ArrowLeft":
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
if (S.selection && S.selection.length >= 2) {
|
|
229
|
+
update(selectNode(S, parentElementPath(S.selection)));
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
case "ArrowRight":
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
if (S.selection) {
|
|
235
|
+
const node = getNodeAtPath(S.document, S.selection);
|
|
236
|
+
if (node?.children?.length > 0) {
|
|
237
|
+
update(selectNode(S, [...S.selection, "children", 0]));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Block ctrl+scroll (browser zoom) on all non-canvas areas
|
|
245
|
+
document.addEventListener(
|
|
246
|
+
"wheel",
|
|
247
|
+
(/** @type {any} */ e) => {
|
|
248
|
+
if ((e.ctrlKey || e.metaKey) && !canvasWrap.contains(e.target)) {
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
{ passive: false },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* @param {any} S
|
|
258
|
+
* @param {number} [direction]
|
|
259
|
+
*/
|
|
260
|
+
function navigateSelection(S, direction = -1) {
|
|
261
|
+
if (!S.selection) {
|
|
262
|
+
update(selectNode(S, []));
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
if (S.selection.length < 2) return; // can't navigate from root
|
|
266
|
+
|
|
267
|
+
const parent = getNodeAtPath(S.document, /** @type {any} */ (parentElementPath(S.selection)));
|
|
268
|
+
const idx = /** @type {number} */ (childIndex(S.selection));
|
|
269
|
+
const newIdx = idx + direction;
|
|
270
|
+
if (parent?.children && newIdx >= 0 && newIdx < parent.children.length) {
|
|
271
|
+
update(
|
|
272
|
+
selectNode(S, [.../** @type {any[]} */ (parentElementPath(S.selection)), "children", newIdx]),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-menu.js — Shared slash command menu for element insertion
|
|
3
|
+
*
|
|
4
|
+
* A single implementation used by both inline-edit (Edit/Content modes) and component inline
|
|
5
|
+
* editing (Design mode). Renders a Spectrum-styled popover with keyboard navigation. Uses a
|
|
6
|
+
* document-level capturing keydown listener so it intercepts Enter/Arrow/Escape before any
|
|
7
|
+
* element-level handlers.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { html, render as litRender, nothing } from "lit-html";
|
|
11
|
+
|
|
12
|
+
// ─── Commands ─────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const SLASH_COMMANDS = [
|
|
15
|
+
{ label: "Heading 1", tag: "h1", description: "Large heading" },
|
|
16
|
+
{ label: "Heading 2", tag: "h2", description: "Medium heading" },
|
|
17
|
+
{ label: "Heading 3", tag: "h3", description: "Small heading" },
|
|
18
|
+
{ label: "Paragraph", tag: "p", description: "Plain text" },
|
|
19
|
+
{ label: "Bulleted List", tag: "ul", description: "Unordered list" },
|
|
20
|
+
{ label: "Numbered List", tag: "ol", description: "Numbered list" },
|
|
21
|
+
{ label: "Blockquote", tag: "blockquote", description: "Quote block" },
|
|
22
|
+
{ label: "Image", tag: "img", description: "Insert image" },
|
|
23
|
+
{ label: "Horizontal Rule", tag: "hr", description: "Divider line" },
|
|
24
|
+
{ label: "Button", tag: "button", description: "Button element" },
|
|
25
|
+
{ label: "Link", tag: "a", description: "Anchor link" },
|
|
26
|
+
{ label: "Code Block", tag: "pre", description: "Preformatted code" },
|
|
27
|
+
{ label: "Table", tag: "table", description: "Insert table" },
|
|
28
|
+
{ label: "Div", tag: "div", description: "Container" },
|
|
29
|
+
{ label: "Section", tag: "section", description: "Section container" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
const host = document.createElement("div");
|
|
35
|
+
host.style.display = "contents";
|
|
36
|
+
|
|
37
|
+
/** @type {{ onSelect: (cmd: any) => void } | null} */
|
|
38
|
+
let callbacks = null;
|
|
39
|
+
let activeIdx = 0;
|
|
40
|
+
/** @type {any[]} */
|
|
41
|
+
let filteredItems = [];
|
|
42
|
+
let open = false;
|
|
43
|
+
|
|
44
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/** @returns {boolean} */
|
|
47
|
+
export function isSlashMenuOpen() {
|
|
48
|
+
return open;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Show (or update) the slash menu anchored below `anchorEl`.
|
|
53
|
+
*
|
|
54
|
+
* @param {HTMLElement} anchorEl — the element being edited (for positioning)
|
|
55
|
+
* @param {string} filter — current typed filter text (after the "/")
|
|
56
|
+
* @param {{ onSelect: (cmd: any) => void }} cbs
|
|
57
|
+
*/
|
|
58
|
+
export function showSlashMenu(anchorEl, filter, cbs) {
|
|
59
|
+
// Lazily attach host to sp-theme
|
|
60
|
+
if (!host.parentElement) {
|
|
61
|
+
(document.querySelector("sp-theme") || document.body).appendChild(host);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
callbacks = cbs;
|
|
65
|
+
|
|
66
|
+
filteredItems = filter
|
|
67
|
+
? SLASH_COMMANDS.filter(
|
|
68
|
+
(c) => c.label.toLowerCase().includes(filter) || c.tag.toLowerCase().includes(filter),
|
|
69
|
+
)
|
|
70
|
+
: SLASH_COMMANDS;
|
|
71
|
+
|
|
72
|
+
if (!filteredItems.length) {
|
|
73
|
+
dismissSlashMenu();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
activeIdx = 0;
|
|
78
|
+
|
|
79
|
+
const rect = anchorEl.getBoundingClientRect();
|
|
80
|
+
|
|
81
|
+
litRender(
|
|
82
|
+
html`
|
|
83
|
+
<sp-popover
|
|
84
|
+
open
|
|
85
|
+
style="position:fixed;left:${rect.left}px;top:${rect.bottom +
|
|
86
|
+
4}px;z-index:9999;max-height:280px;overflow-y:auto"
|
|
87
|
+
>
|
|
88
|
+
<sp-menu style="min-width:220px">
|
|
89
|
+
${filteredItems.map(
|
|
90
|
+
(cmd, i) => html`
|
|
91
|
+
<sp-menu-item
|
|
92
|
+
?focused=${i === 0}
|
|
93
|
+
@click=${(/** @type {Event} */ e) => {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
e.stopPropagation();
|
|
96
|
+
select(cmd);
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
${cmd.label}
|
|
100
|
+
${cmd.description
|
|
101
|
+
? html`<span slot="description">${cmd.description}</span>`
|
|
102
|
+
: nothing}
|
|
103
|
+
</sp-menu-item>
|
|
104
|
+
`,
|
|
105
|
+
)}
|
|
106
|
+
</sp-menu>
|
|
107
|
+
</sp-popover>
|
|
108
|
+
`,
|
|
109
|
+
host,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (!open) {
|
|
113
|
+
open = true;
|
|
114
|
+
document.addEventListener("keydown", onKeydown, true); // capture phase
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function dismissSlashMenu() {
|
|
119
|
+
if (!open) return;
|
|
120
|
+
open = false;
|
|
121
|
+
callbacks = null;
|
|
122
|
+
filteredItems = [];
|
|
123
|
+
document.removeEventListener("keydown", onKeydown, true);
|
|
124
|
+
litRender(nothing, host);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Internal ─────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
/** @param {any} cmd */
|
|
130
|
+
function select(cmd) {
|
|
131
|
+
const cbs = callbacks;
|
|
132
|
+
dismissSlashMenu();
|
|
133
|
+
cbs?.onSelect(cmd);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** @param {KeyboardEvent} e */
|
|
137
|
+
function onKeydown(e) {
|
|
138
|
+
if (!open) return;
|
|
139
|
+
|
|
140
|
+
const items = /** @type {NodeListOf<Element>} */ (host.querySelectorAll("sp-menu-item"));
|
|
141
|
+
if (!items.length) return;
|
|
142
|
+
|
|
143
|
+
if (e.key === "ArrowDown") {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
e.stopPropagation();
|
|
146
|
+
items[activeIdx]?.removeAttribute("focused");
|
|
147
|
+
activeIdx = (activeIdx + 1) % items.length;
|
|
148
|
+
items[activeIdx]?.setAttribute("focused", "");
|
|
149
|
+
items[activeIdx]?.scrollIntoView({ block: "nearest" });
|
|
150
|
+
} else if (e.key === "ArrowUp") {
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
e.stopPropagation();
|
|
153
|
+
items[activeIdx]?.removeAttribute("focused");
|
|
154
|
+
activeIdx = (activeIdx - 1 + items.length) % items.length;
|
|
155
|
+
items[activeIdx]?.setAttribute("focused", "");
|
|
156
|
+
items[activeIdx]?.scrollIntoView({ block: "nearest" });
|
|
157
|
+
} else if (e.key === "Enter") {
|
|
158
|
+
e.preventDefault();
|
|
159
|
+
e.stopPropagation();
|
|
160
|
+
const cmd = filteredItems[activeIdx];
|
|
161
|
+
if (cmd) select(cmd);
|
|
162
|
+
} else if (e.key === "Escape") {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
e.stopPropagation();
|
|
165
|
+
dismissSlashMenu();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Component registry — cached list of project components discovered via the platform. */
|
|
2
|
+
|
|
3
|
+
import { getPlatform } from "../platform.js";
|
|
4
|
+
import { projectState } from "../store.js";
|
|
5
|
+
|
|
6
|
+
/** @type {any[]} */
|
|
7
|
+
export let componentRegistry = []; // cached list from /__studio/components
|
|
8
|
+
export let _componentRegistryLoaded = false;
|
|
9
|
+
|
|
10
|
+
export async function loadComponentRegistry() {
|
|
11
|
+
try {
|
|
12
|
+
const platform = getPlatform();
|
|
13
|
+
componentRegistry = await platform.discoverComponents(projectState?.projectRoot || undefined);
|
|
14
|
+
_componentRegistryLoaded = true;
|
|
15
|
+
} catch {
|
|
16
|
+
_componentRegistryLoaded = true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {any} fromDocPath
|
|
22
|
+
* @param {any} toCompPath
|
|
23
|
+
*/
|
|
24
|
+
export function computeRelativePath(fromDocPath, toCompPath) {
|
|
25
|
+
if (!fromDocPath) return `./${toCompPath}`;
|
|
26
|
+
const fromDir = fromDocPath.substring(0, fromDocPath.lastIndexOf("/"));
|
|
27
|
+
const fromParts = fromDir.split("/").filter(Boolean);
|
|
28
|
+
const toParts = toCompPath.split("/").filter(Boolean);
|
|
29
|
+
let common = 0;
|
|
30
|
+
while (
|
|
31
|
+
common < fromParts.length &&
|
|
32
|
+
common < toParts.length &&
|
|
33
|
+
fromParts[common] === toParts[common]
|
|
34
|
+
) {
|
|
35
|
+
common++;
|
|
36
|
+
}
|
|
37
|
+
const ups = fromParts.length - common;
|
|
38
|
+
const remaining = toParts.slice(common);
|
|
39
|
+
return (ups > 0 ? "../".repeat(ups) : "./") + remaining.join("/");
|
|
40
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Operations — open, load, save documents.
|
|
3
|
+
*
|
|
4
|
+
* Each function that mutates state accepts a `commit(newState)` callback so the caller (studio.js)
|
|
5
|
+
* can assign S and trigger render().
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { unified } from "unified";
|
|
9
|
+
import remarkParse from "remark-parse";
|
|
10
|
+
import remarkStringify from "remark-stringify";
|
|
11
|
+
import remarkFrontmatter from "remark-frontmatter";
|
|
12
|
+
import remarkGfm from "remark-gfm";
|
|
13
|
+
import remarkDirective from "remark-directive";
|
|
14
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
15
|
+
import { mdToJx, jxToMd } from "../markdown/md-convert.js";
|
|
16
|
+
import { createState } from "../store.js";
|
|
17
|
+
import { locateDocument } from "../services/code-services.js";
|
|
18
|
+
import { statusMessage } from "../panels/statusbar.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Open a file via the File System Access API (or fallback input).
|
|
22
|
+
*
|
|
23
|
+
* @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
|
|
24
|
+
*/
|
|
25
|
+
export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar }) {
|
|
26
|
+
try {
|
|
27
|
+
if ("showOpenFilePicker" in window) {
|
|
28
|
+
const [handle] = await /** @type {any} */ (window).showOpenFilePicker({
|
|
29
|
+
types: [
|
|
30
|
+
{ description: "Jx Component", accept: { "application/json": [".json"] } },
|
|
31
|
+
{ description: "Markdown Content", accept: { "text/markdown": [".md"] } },
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
const file = await handle.getFile();
|
|
35
|
+
const text = await file.text();
|
|
36
|
+
|
|
37
|
+
if (handle.name.endsWith(".md")) {
|
|
38
|
+
const newState = loadMarkdown(text, handle);
|
|
39
|
+
commit(newState);
|
|
40
|
+
} else {
|
|
41
|
+
const doc = JSON.parse(text);
|
|
42
|
+
const newState = createState(doc);
|
|
43
|
+
newState.fileHandle = handle;
|
|
44
|
+
newState.dirty = false;
|
|
45
|
+
newState.documentPath = await locateDocument(handle.name);
|
|
46
|
+
await loadCompanionJS(handle, newState);
|
|
47
|
+
commit(newState);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
statusMessage(`Opened ${handle.name}`);
|
|
51
|
+
} else {
|
|
52
|
+
// Fallback: file input
|
|
53
|
+
const input = document.createElement("input");
|
|
54
|
+
input.type = "file";
|
|
55
|
+
input.accept = ".json,.md";
|
|
56
|
+
input.onchange = async () => {
|
|
57
|
+
const file = input.files?.[0];
|
|
58
|
+
if (!file) return;
|
|
59
|
+
const text = await file.text();
|
|
60
|
+
|
|
61
|
+
if (file.name.endsWith(".md")) {
|
|
62
|
+
const newState = loadMarkdown(text, null);
|
|
63
|
+
commit(newState);
|
|
64
|
+
} else {
|
|
65
|
+
const doc = JSON.parse(text);
|
|
66
|
+
const newState = createState(doc);
|
|
67
|
+
newState.dirty = false;
|
|
68
|
+
commit(newState);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
statusMessage(`Opened ${file.name}`);
|
|
72
|
+
};
|
|
73
|
+
input.click();
|
|
74
|
+
}
|
|
75
|
+
} catch (/** @type {any} */ e) {
|
|
76
|
+
if (e.name !== "AbortError") statusMessage(`Error: ${e.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse a markdown string into a Jx state object (pure — no side effects).
|
|
82
|
+
*
|
|
83
|
+
* @param {any} source Markdown text
|
|
84
|
+
* @param {any} fileHandle File handle (or null)
|
|
85
|
+
* @returns {any} A new state object ready for commit()
|
|
86
|
+
*/
|
|
87
|
+
export function loadMarkdown(source, fileHandle) {
|
|
88
|
+
const processor = unified()
|
|
89
|
+
.use(remarkParse)
|
|
90
|
+
.use(remarkFrontmatter, ["yaml"])
|
|
91
|
+
.use(remarkGfm)
|
|
92
|
+
.use(remarkDirective);
|
|
93
|
+
|
|
94
|
+
const mdast = processor.parse(source);
|
|
95
|
+
|
|
96
|
+
// Extract frontmatter from the first YAML node
|
|
97
|
+
let frontmatter = {};
|
|
98
|
+
const yamlNode = mdast.children.find((n) => n.type === "yaml");
|
|
99
|
+
if (yamlNode) {
|
|
100
|
+
try {
|
|
101
|
+
frontmatter = parseYaml(yamlNode.value) ?? {};
|
|
102
|
+
} catch {}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const jxTree = mdToJx(mdast);
|
|
106
|
+
|
|
107
|
+
const newState = createState(jxTree);
|
|
108
|
+
newState.mode = "content";
|
|
109
|
+
newState.content = { frontmatter };
|
|
110
|
+
newState.fileHandle = fileHandle;
|
|
111
|
+
newState.dirty = false;
|
|
112
|
+
return newState;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Load companion JS file metadata into state.
|
|
117
|
+
*
|
|
118
|
+
* @param {any} handle
|
|
119
|
+
* @param {any} state State object to mutate in-place
|
|
120
|
+
*/
|
|
121
|
+
async function loadCompanionJS(handle, state) {
|
|
122
|
+
try {
|
|
123
|
+
if (handle.getParent) {
|
|
124
|
+
// Not yet available in any browser; skip for now
|
|
125
|
+
}
|
|
126
|
+
if (state.document.$handlers) {
|
|
127
|
+
state.handlersSource = `// Companion file: ${state.document.$handlers}\n// (Read-only in builder — edit the JS file directly)`;
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Save the current document to disk (or download as fallback).
|
|
134
|
+
*
|
|
135
|
+
* @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
|
|
136
|
+
*/
|
|
137
|
+
export async function saveFile({ S, commit, renderToolbar }) {
|
|
138
|
+
try {
|
|
139
|
+
const isContent = S.mode === "content";
|
|
140
|
+
let output, mimeType, ext, description;
|
|
141
|
+
|
|
142
|
+
if (isContent) {
|
|
143
|
+
const mdast = jxToMd(S.document);
|
|
144
|
+
const md = unified()
|
|
145
|
+
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
|
146
|
+
.stringify(mdast);
|
|
147
|
+
|
|
148
|
+
const fm = S.content?.frontmatter;
|
|
149
|
+
const hasFrontmatter = fm && Object.keys(fm).length > 0;
|
|
150
|
+
output = hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
|
|
151
|
+
mimeType = "text/markdown";
|
|
152
|
+
ext = ".md";
|
|
153
|
+
description = "Markdown Content";
|
|
154
|
+
} else {
|
|
155
|
+
output = JSON.stringify(S.document, null, 2);
|
|
156
|
+
mimeType = "application/json";
|
|
157
|
+
ext = ".json";
|
|
158
|
+
description = "Jx Component";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (S.fileHandle && "createWritable" in S.fileHandle) {
|
|
162
|
+
const writable = await S.fileHandle.createWritable();
|
|
163
|
+
await writable.write(output);
|
|
164
|
+
await writable.close();
|
|
165
|
+
commit({ ...S, dirty: false });
|
|
166
|
+
renderToolbar();
|
|
167
|
+
statusMessage("Saved");
|
|
168
|
+
} else if ("showSaveFilePicker" in window) {
|
|
169
|
+
const handle = await /** @type {any} */ (window).showSaveFilePicker({
|
|
170
|
+
suggestedName: isContent ? "content.md" : "component.json",
|
|
171
|
+
types: [{ description, accept: { [mimeType]: [ext] } }],
|
|
172
|
+
});
|
|
173
|
+
const writable = await handle.createWritable();
|
|
174
|
+
await writable.write(output);
|
|
175
|
+
await writable.close();
|
|
176
|
+
commit({ ...S, fileHandle: handle, dirty: false });
|
|
177
|
+
renderToolbar();
|
|
178
|
+
statusMessage(`Saved as ${handle.name}`);
|
|
179
|
+
} else {
|
|
180
|
+
// Fallback: download
|
|
181
|
+
const blob = new Blob([output], { type: mimeType });
|
|
182
|
+
const url = URL.createObjectURL(blob);
|
|
183
|
+
const a = document.createElement("a");
|
|
184
|
+
a.href = url;
|
|
185
|
+
a.download = isContent ? "content.md" : "component.json";
|
|
186
|
+
a.click();
|
|
187
|
+
URL.revokeObjectURL(url);
|
|
188
|
+
commit({ ...S, dirty: false });
|
|
189
|
+
renderToolbar();
|
|
190
|
+
statusMessage("Downloaded");
|
|
191
|
+
}
|
|
192
|
+
} catch (/** @type {any} */ e) {
|
|
193
|
+
if (e.name !== "AbortError") statusMessage(`Save error: ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|