@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.
@@ -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
+ }