@jxsuite/studio 0.0.1 → 0.5.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 +47638 -33445
- package/dist/studio.js.map +449 -344
- package/package.json +44 -33
- package/src/browse/browse.js +414 -0
- package/src/editor/context-menu.js +48 -1
- package/src/editor/convert-to-component.js +208 -0
- package/src/editor/inline-edit.js +33 -6
- package/src/editor/shortcuts.js +6 -1
- package/src/files/components.js +4 -2
- package/src/files/file-ops.js +102 -54
- package/src/files/files.js +22 -8
- package/src/markdown/md-convert.js +309 -11
- package/src/panels/activity-bar.js +3 -0
- package/src/panels/head-panel.js +576 -0
- package/src/panels/overlays.js +125 -0
- package/src/panels/right-panel.js +104 -0
- package/src/panels/shared.js +41 -0
- package/src/panels/signals-panel.js +95 -94
- package/src/panels/toolbar.js +217 -0
- package/src/platforms/devserver.js +58 -16
- package/src/settings/collections-editor.js +428 -0
- package/src/settings/defs-editor.js +418 -0
- package/src/settings/schema-field-ui.js +329 -0
- package/src/state.js +99 -2
- package/src/store.js +77 -41
- package/src/studio.js +1523 -1375
- package/src/ui/button-group.js +91 -0
- package/src/ui/color-selector.js +299 -0
- package/src/ui/field-row.js +47 -0
- package/src/ui/media-picker.js +172 -0
- package/src/ui/panel-resize.js +96 -0
- package/src/ui/spectrum.js +36 -2
- package/src/ui/unit-selector.js +106 -0
- package/src/ui/{jx-styled-combobox.js → value-selector.js} +7 -7
- package/src/ui/widgets.js +106 -0
- package/src/utils/inherited-style.js +54 -0
- package/src/utils/studio-utils.js +32 -0
- package/src/view.js +45 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// ─── Convert to Component ─────────────────────────────────────────────────────
|
|
2
|
+
import { html, render as litRender } from "lit-html";
|
|
3
|
+
import { update, getNodeAtPath, applyMutation, parentElementPath, childIndex } from "../store.js";
|
|
4
|
+
import {
|
|
5
|
+
computeRelativePath,
|
|
6
|
+
loadComponentRegistry,
|
|
7
|
+
componentRegistry,
|
|
8
|
+
} from "../files/components.js";
|
|
9
|
+
import { getPlatform } from "../platform.js";
|
|
10
|
+
import { statusMessage } from "../panels/statusbar.js";
|
|
11
|
+
|
|
12
|
+
const VALID_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert the currently selected element into a reusable component.
|
|
16
|
+
*
|
|
17
|
+
* @param {any} S - Current studio state
|
|
18
|
+
*/
|
|
19
|
+
export async function convertToComponent(S) {
|
|
20
|
+
if (!S.selection || S.selection.length < 2) return;
|
|
21
|
+
|
|
22
|
+
const node = getNodeAtPath(S.document, S.selection);
|
|
23
|
+
if (!node || !node.tagName) return;
|
|
24
|
+
|
|
25
|
+
const defaultName = deriveDefaultName(node);
|
|
26
|
+
const name = await promptComponentName(defaultName);
|
|
27
|
+
if (!name) return;
|
|
28
|
+
|
|
29
|
+
// Extract component definition
|
|
30
|
+
const componentDef = extractComponentDef(node);
|
|
31
|
+
componentDef.tagName = name;
|
|
32
|
+
|
|
33
|
+
// Compute paths
|
|
34
|
+
const componentFile = "components/" + name + ".json";
|
|
35
|
+
const refPath = computeRelativePath(S.documentPath, componentFile);
|
|
36
|
+
|
|
37
|
+
// Single atomic mutation: replace node + add $elements ref
|
|
38
|
+
const selectionPath = S.selection;
|
|
39
|
+
const newState = applyMutation(S, (doc) => {
|
|
40
|
+
// Navigate to parent's children array and replace the node
|
|
41
|
+
const pp = parentElementPath(selectionPath) ?? [];
|
|
42
|
+
const idx = childIndex(selectionPath);
|
|
43
|
+
let parent = doc;
|
|
44
|
+
for (const seg of pp) parent = parent[seg];
|
|
45
|
+
parent.children[idx] = { tagName: name };
|
|
46
|
+
|
|
47
|
+
// Ensure $elements exists and add the $ref
|
|
48
|
+
if (!doc.$elements) doc.$elements = [];
|
|
49
|
+
const alreadyReferenced = doc.$elements.some(
|
|
50
|
+
(/** @type {any} */ el) => el && el.$ref === refPath,
|
|
51
|
+
);
|
|
52
|
+
if (!alreadyReferenced) {
|
|
53
|
+
doc.$elements.push({ $ref: refPath });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
update(newState);
|
|
58
|
+
|
|
59
|
+
// Write component file and refresh registry
|
|
60
|
+
try {
|
|
61
|
+
const platform = getPlatform();
|
|
62
|
+
await platform.writeFile(componentFile, JSON.stringify(componentDef, null, 2));
|
|
63
|
+
await loadComponentRegistry();
|
|
64
|
+
statusMessage(`Converted to <${name}>`);
|
|
65
|
+
} catch (/** @type {any} */ err) {
|
|
66
|
+
statusMessage(`Error saving component: ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Derive a default tag name from a node.
|
|
72
|
+
*
|
|
73
|
+
* @param {any} node
|
|
74
|
+
* @returns {string}
|
|
75
|
+
*/
|
|
76
|
+
function deriveDefaultName(node) {
|
|
77
|
+
if (node.$id && node.$id.includes("-")) return node.$id.toLowerCase();
|
|
78
|
+
const tag = (node.tagName ?? "div").toLowerCase();
|
|
79
|
+
return tag.includes("-") ? tag : "jx-" + tag;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Deep clone a node and strip page-specific keys.
|
|
84
|
+
*
|
|
85
|
+
* @param {any} node
|
|
86
|
+
* @returns {any}
|
|
87
|
+
*/
|
|
88
|
+
function extractComponentDef(node) {
|
|
89
|
+
const clone = structuredClone(node);
|
|
90
|
+
delete clone.$id;
|
|
91
|
+
delete clone.$layout;
|
|
92
|
+
delete clone.$paths;
|
|
93
|
+
return clone;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Validate a component name against naming rules and existing registry.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} val
|
|
100
|
+
* @returns {{ valid: boolean; error: string }}
|
|
101
|
+
*/
|
|
102
|
+
function validateName(val) {
|
|
103
|
+
val = val.trim().toLowerCase();
|
|
104
|
+
if (!val.includes("-")) {
|
|
105
|
+
return { valid: false, error: "Name must contain a hyphen (e.g. my-component)" };
|
|
106
|
+
}
|
|
107
|
+
if (!VALID_NAME.test(val)) {
|
|
108
|
+
return { valid: false, error: "Lowercase letters, digits, and hyphens only" };
|
|
109
|
+
}
|
|
110
|
+
const exists = componentRegistry.some((/** @type {any} */ c) => c.tagName === val);
|
|
111
|
+
if (exists) {
|
|
112
|
+
return { valid: false, error: `Component <${val}> already exists` };
|
|
113
|
+
}
|
|
114
|
+
return { valid: true, error: "" };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Show a naming dialog using Lit-rendered sp-dialog-wrapper.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} defaultName
|
|
121
|
+
* @returns {Promise<string | null>}
|
|
122
|
+
*/
|
|
123
|
+
function promptComponentName(defaultName) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
let value = defaultName;
|
|
126
|
+
let error = "";
|
|
127
|
+
let resolved = false;
|
|
128
|
+
|
|
129
|
+
const host = document.createElement("div");
|
|
130
|
+
const themeRoot = document.querySelector("sp-theme") || document.body;
|
|
131
|
+
themeRoot.appendChild(host);
|
|
132
|
+
|
|
133
|
+
function cleanup() {
|
|
134
|
+
if (resolved) return;
|
|
135
|
+
resolved = true;
|
|
136
|
+
host.remove();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function confirm() {
|
|
140
|
+
const result = validateName(value);
|
|
141
|
+
if (!result.valid) {
|
|
142
|
+
error = result.error;
|
|
143
|
+
renderDialog();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve(value.trim().toLowerCase());
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function cancel() {
|
|
151
|
+
cleanup();
|
|
152
|
+
resolve(null);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function onInput(/** @type {Event} */ e) {
|
|
156
|
+
value = /** @type {any} */ (e.target).value || "";
|
|
157
|
+
const result = validateName(value);
|
|
158
|
+
error = result.valid ? "" : result.error;
|
|
159
|
+
renderDialog();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function onKeydown(/** @type {KeyboardEvent} */ e) {
|
|
163
|
+
if (e.key === "Enter") confirm();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderDialog() {
|
|
167
|
+
litRender(
|
|
168
|
+
html`
|
|
169
|
+
<sp-dialog-wrapper
|
|
170
|
+
open
|
|
171
|
+
underlay
|
|
172
|
+
headline="Convert to Component"
|
|
173
|
+
confirm-label="Convert"
|
|
174
|
+
cancel-label="Cancel"
|
|
175
|
+
size="s"
|
|
176
|
+
@confirm=${confirm}
|
|
177
|
+
@cancel=${cancel}
|
|
178
|
+
@close=${cancel}
|
|
179
|
+
>
|
|
180
|
+
<p>Enter a hyphenated tag name for the new component.</p>
|
|
181
|
+
<sp-textfield
|
|
182
|
+
placeholder="my-component"
|
|
183
|
+
value=${value}
|
|
184
|
+
?negative=${!!error}
|
|
185
|
+
@input=${onInput}
|
|
186
|
+
@keydown=${onKeydown}
|
|
187
|
+
>
|
|
188
|
+
<sp-help-text slot="negative-help-text">${error}</sp-help-text>
|
|
189
|
+
</sp-textfield>
|
|
190
|
+
</sp-dialog-wrapper>
|
|
191
|
+
`,
|
|
192
|
+
host,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
renderDialog();
|
|
197
|
+
|
|
198
|
+
// Focus the textfield after Spectrum renders
|
|
199
|
+
requestAnimationFrame(() => {
|
|
200
|
+
const tf = /** @type {any} */ (host.querySelector("sp-textfield"));
|
|
201
|
+
if (tf) {
|
|
202
|
+
tf.focus();
|
|
203
|
+
const input = tf.shadowRoot?.querySelector("input");
|
|
204
|
+
if (input) input.select();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -94,8 +94,8 @@ let activePath = null; // JSON path to the active element
|
|
|
94
94
|
let commitFn = null; // function(path, newChildren, newTextContent) to commit changes
|
|
95
95
|
/** @type {((path: any[], beforeChildren: any, afterChildren: any) => void) | null} */
|
|
96
96
|
let splitFn = null; // function(path, beforeChildren, afterChildren) to split paragraph
|
|
97
|
-
/** @type {((path: any[], elementDef: any) => void) | null} */
|
|
98
|
-
let insertFn = null; // function(path, elementDef) to insert after current block
|
|
97
|
+
/** @type {((path: any[], elementDef: any, commitData?: any) => void) | null} */
|
|
98
|
+
let insertFn = null; // function(path, elementDef, commitData?) to insert after current block
|
|
99
99
|
/** @type {(() => void) | null} */
|
|
100
100
|
let endFn = null; // function() called when editing stops
|
|
101
101
|
|
|
@@ -337,14 +337,26 @@ function handleEnterKey() {
|
|
|
337
337
|
|
|
338
338
|
// Stop editing before mutating state (which will re-render)
|
|
339
339
|
const path = [...activePath];
|
|
340
|
+
const split = splitFn;
|
|
341
|
+
|
|
340
342
|
activeEl.contentEditable = "false";
|
|
341
343
|
activeEl.removeEventListener("keydown", handleKeydown);
|
|
342
344
|
activeEl.removeEventListener("input", handleInput);
|
|
343
345
|
activeEl.removeEventListener("blur", handleBlur);
|
|
344
346
|
activeEl.removeEventListener("paste", handlePaste);
|
|
345
347
|
activeEl = null;
|
|
348
|
+
activePath = null;
|
|
349
|
+
commitFn = null;
|
|
350
|
+
splitFn = null;
|
|
351
|
+
insertFn = null;
|
|
352
|
+
|
|
353
|
+
if (endFn) {
|
|
354
|
+
const fn = endFn;
|
|
355
|
+
endFn = null;
|
|
356
|
+
fn();
|
|
357
|
+
}
|
|
346
358
|
|
|
347
|
-
|
|
359
|
+
split(path, beforeChildren, afterChildren);
|
|
348
360
|
}
|
|
349
361
|
|
|
350
362
|
// ─── Content sync: DOM → Jx ────────────────────────────────────────────
|
|
@@ -582,16 +594,31 @@ function handleSlashSelect(cmd) {
|
|
|
582
594
|
}
|
|
583
595
|
}
|
|
584
596
|
|
|
585
|
-
commitChanges()
|
|
597
|
+
// Compute commit data inline instead of calling commitChanges() — avoids a separate
|
|
598
|
+
// update() call that would race with the insertFn update() (two concurrent async renders).
|
|
599
|
+
normalizeInlineContent(activeEl);
|
|
600
|
+
const commitResult = elementToJx(activeEl);
|
|
586
601
|
|
|
587
602
|
const path = [...activePath];
|
|
603
|
+
const insert = insertFn;
|
|
604
|
+
|
|
588
605
|
activeEl.contentEditable = "false";
|
|
589
606
|
activeEl.removeEventListener("keydown", handleKeydown);
|
|
590
607
|
activeEl.removeEventListener("input", handleInput);
|
|
591
608
|
activeEl.removeEventListener("blur", handleBlur);
|
|
592
609
|
activeEl.removeEventListener("paste", handlePaste);
|
|
593
610
|
activeEl = null;
|
|
611
|
+
activePath = null;
|
|
612
|
+
commitFn = null;
|
|
613
|
+
splitFn = null;
|
|
614
|
+
insertFn = null;
|
|
615
|
+
|
|
616
|
+
if (endFn) {
|
|
617
|
+
const fn = endFn;
|
|
618
|
+
endFn = null;
|
|
619
|
+
fn();
|
|
620
|
+
}
|
|
594
621
|
|
|
595
|
-
//
|
|
596
|
-
|
|
622
|
+
// Pass commit data so onInsert can batch commit + insert into a single update()
|
|
623
|
+
insert(path, cmd, commitResult);
|
|
597
624
|
}
|
package/src/editor/shortcuts.js
CHANGED
|
@@ -115,7 +115,12 @@ export function initShortcuts(getContext) {
|
|
|
115
115
|
const mod = e.ctrlKey || e.metaKey;
|
|
116
116
|
|
|
117
117
|
// Don't intercept when typing in inputs or contenteditable
|
|
118
|
-
if (
|
|
118
|
+
if (
|
|
119
|
+
e.target instanceof HTMLElement &&
|
|
120
|
+
e.target.matches(
|
|
121
|
+
"input, textarea, select, sp-textfield, sp-search, sp-number-field, sp-picker",
|
|
122
|
+
)
|
|
123
|
+
) {
|
|
119
124
|
if (mod && e.key === "s") {
|
|
120
125
|
e.preventDefault();
|
|
121
126
|
saveFile();
|
package/src/files/components.js
CHANGED
|
@@ -23,9 +23,11 @@ export async function loadComponentRegistry() {
|
|
|
23
23
|
*/
|
|
24
24
|
export function computeRelativePath(fromDocPath, toCompPath) {
|
|
25
25
|
if (!fromDocPath) return `./${toCompPath}`;
|
|
26
|
-
const
|
|
26
|
+
const from = fromDocPath.replaceAll("\\", "/");
|
|
27
|
+
const to = toCompPath.replaceAll("\\", "/");
|
|
28
|
+
const fromDir = from.substring(0, from.lastIndexOf("/"));
|
|
27
29
|
const fromParts = fromDir.split("/").filter(Boolean);
|
|
28
|
-
const toParts =
|
|
30
|
+
const toParts = to.split("/").filter(Boolean);
|
|
29
31
|
let common = 0;
|
|
30
32
|
while (
|
|
31
33
|
common < fromParts.length &&
|
package/src/files/file-ops.js
CHANGED
|
@@ -6,16 +6,14 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { unified } from "unified";
|
|
9
|
-
import remarkParse from "remark-parse";
|
|
10
9
|
import remarkStringify from "remark-stringify";
|
|
11
|
-
import remarkFrontmatter from "remark-frontmatter";
|
|
12
|
-
import remarkGfm from "remark-gfm";
|
|
13
10
|
import remarkDirective from "remark-directive";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
11
|
+
import { stringify as stringifyYaml } from "yaml";
|
|
12
|
+
import { jxToMd, jxDocToMd } from "../markdown/md-convert.js";
|
|
16
13
|
import { createState } from "../store.js";
|
|
17
14
|
import { locateDocument } from "../services/code-services.js";
|
|
18
15
|
import { statusMessage } from "../panels/statusbar.js";
|
|
16
|
+
import { getPlatform } from "../platform.js";
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
19
|
* Open a file via the File System Access API (or fallback input).
|
|
@@ -35,7 +33,7 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
|
|
|
35
33
|
const text = await file.text();
|
|
36
34
|
|
|
37
35
|
if (handle.name.endsWith(".md")) {
|
|
38
|
-
const newState = loadMarkdown(text, handle);
|
|
36
|
+
const newState = await loadMarkdown(text, handle);
|
|
39
37
|
commit(newState);
|
|
40
38
|
} else {
|
|
41
39
|
const doc = JSON.parse(text);
|
|
@@ -59,7 +57,7 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
|
|
|
59
57
|
const text = await file.text();
|
|
60
58
|
|
|
61
59
|
if (file.name.endsWith(".md")) {
|
|
62
|
-
const newState = loadMarkdown(text, null);
|
|
60
|
+
const newState = await loadMarkdown(text, null);
|
|
63
61
|
commit(newState);
|
|
64
62
|
} else {
|
|
65
63
|
const doc = JSON.parse(text);
|
|
@@ -80,33 +78,46 @@ export async function openFile({ S: _S, commit, renderToolbar: _renderToolbar })
|
|
|
80
78
|
/**
|
|
81
79
|
* Parse a markdown string into a Jx state object (pure — no side effects).
|
|
82
80
|
*
|
|
81
|
+
* All markdown goes through `transpileJxMarkdown()`. Content documents (no hyphenated `tagName`)
|
|
82
|
+
* are wrapped in a `{ tagName: "div", $id: "content" }` root to match the studio's content
|
|
83
|
+
* contract.
|
|
84
|
+
*
|
|
83
85
|
* @param {any} source Markdown text
|
|
84
86
|
* @param {any} fileHandle File handle (or null)
|
|
85
|
-
* @returns {any} A new state object ready for commit()
|
|
87
|
+
* @returns {Promise<any>} A new state object ready for commit()
|
|
86
88
|
*/
|
|
87
|
-
export function loadMarkdown(source, fileHandle) {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
frontmatter = parseYaml(yamlNode.value) ?? {};
|
|
102
|
-
} catch {}
|
|
89
|
+
export async function loadMarkdown(source, fileHandle) {
|
|
90
|
+
const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
|
|
91
|
+
const doc = /** @type {any} */ (transpileJxMarkdown(source));
|
|
92
|
+
|
|
93
|
+
const isComponent = doc.tagName && String(doc.tagName).includes("-");
|
|
94
|
+
|
|
95
|
+
if (isComponent) {
|
|
96
|
+
const newState = /** @type {any} */ (createState(doc));
|
|
97
|
+
newState.sourceFormat = "md";
|
|
98
|
+
newState.rawMarkdown = source;
|
|
99
|
+
newState.fileHandle = fileHandle;
|
|
100
|
+
newState.dirty = false;
|
|
101
|
+
return newState;
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
// Content markdown — children form the root-level document body
|
|
105
|
+
const contentDoc = {
|
|
106
|
+
children: doc.children ?? [],
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Extract frontmatter keys (everything except children) as content metadata
|
|
110
|
+
/** @type {Record<string, any>} */
|
|
111
|
+
const frontmatter = {};
|
|
112
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
113
|
+
if (key !== "children") frontmatter[key] = value;
|
|
114
|
+
}
|
|
106
115
|
|
|
107
|
-
const newState = createState(
|
|
116
|
+
const newState = /** @type {any} */ (createState(contentDoc));
|
|
117
|
+
newState.sourceFormat = "md";
|
|
108
118
|
newState.mode = "content";
|
|
109
119
|
newState.content = { frontmatter };
|
|
120
|
+
newState.rawMarkdown = source;
|
|
110
121
|
newState.fileHandle = fileHandle;
|
|
111
122
|
newState.dirty = false;
|
|
112
123
|
return newState;
|
|
@@ -130,52 +141,66 @@ async function loadCompanionJS(handle, state) {
|
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
/**
|
|
133
|
-
* Save the current document to
|
|
144
|
+
* Save the current document back to its source location.
|
|
134
145
|
*
|
|
135
146
|
* @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
|
|
136
147
|
*/
|
|
137
148
|
export async function saveFile({ S, commit, renderToolbar }) {
|
|
138
149
|
try {
|
|
139
|
-
const
|
|
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
|
-
}
|
|
150
|
+
const output = serializeDocument(S);
|
|
160
151
|
|
|
161
|
-
if (S.
|
|
152
|
+
if (S.documentPath) {
|
|
153
|
+
// Project file — save via platform
|
|
154
|
+
const platform = getPlatform();
|
|
155
|
+
await platform.writeFile(S.documentPath, output);
|
|
156
|
+
commit({ ...S, dirty: false });
|
|
157
|
+
renderToolbar();
|
|
158
|
+
statusMessage("Saved");
|
|
159
|
+
} else if (S.fileHandle && "createWritable" in S.fileHandle) {
|
|
160
|
+
// Standalone file opened via FS Access API
|
|
162
161
|
const writable = await S.fileHandle.createWritable();
|
|
163
162
|
await writable.write(output);
|
|
164
163
|
await writable.close();
|
|
165
164
|
commit({ ...S, dirty: false });
|
|
166
165
|
renderToolbar();
|
|
167
166
|
statusMessage("Saved");
|
|
168
|
-
} else
|
|
167
|
+
} else {
|
|
168
|
+
statusMessage("No save target — use Export");
|
|
169
|
+
}
|
|
170
|
+
} catch (/** @type {any} */ e) {
|
|
171
|
+
if (e.name !== "AbortError") statusMessage(`Save error: ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Export the current document to a new location (Save As / download).
|
|
177
|
+
*
|
|
178
|
+
* @param {{ S: any; commit: (s: any) => void; renderToolbar: () => void }} ctx
|
|
179
|
+
*/
|
|
180
|
+
export async function exportFile({ S, commit, renderToolbar }) {
|
|
181
|
+
try {
|
|
182
|
+
const isContent = S.mode === "content";
|
|
183
|
+
const output = serializeDocument(S);
|
|
184
|
+
const mimeType = isContent ? "text/markdown" : "application/json";
|
|
185
|
+
const ext = isContent ? ".md" : ".json";
|
|
186
|
+
const description = isContent ? "Markdown Content" : "Jx Component";
|
|
187
|
+
|
|
188
|
+
if ("showSaveFilePicker" in window) {
|
|
189
|
+
const suggestedName = S.documentPath
|
|
190
|
+
? S.documentPath.split("/").pop()
|
|
191
|
+
: isContent
|
|
192
|
+
? "content.md"
|
|
193
|
+
: "component.json";
|
|
169
194
|
const handle = await /** @type {any} */ (window).showSaveFilePicker({
|
|
170
|
-
suggestedName
|
|
195
|
+
suggestedName,
|
|
171
196
|
types: [{ description, accept: { [mimeType]: [ext] } }],
|
|
172
197
|
});
|
|
173
198
|
const writable = await handle.createWritable();
|
|
174
199
|
await writable.write(output);
|
|
175
200
|
await writable.close();
|
|
176
|
-
commit({ ...S,
|
|
201
|
+
commit({ ...S, dirty: false });
|
|
177
202
|
renderToolbar();
|
|
178
|
-
statusMessage(`
|
|
203
|
+
statusMessage(`Exported as ${handle.name}`);
|
|
179
204
|
} else {
|
|
180
205
|
// Fallback: download
|
|
181
206
|
const blob = new Blob([output], { type: mimeType });
|
|
@@ -190,6 +215,29 @@ export async function saveFile({ S, commit, renderToolbar }) {
|
|
|
190
215
|
statusMessage("Downloaded");
|
|
191
216
|
}
|
|
192
217
|
} catch (/** @type {any} */ e) {
|
|
193
|
-
if (e.name !== "AbortError") statusMessage(`
|
|
218
|
+
if (e.name !== "AbortError") statusMessage(`Export error: ${e.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Serialize the current document to its output format (JSON or Markdown).
|
|
224
|
+
*
|
|
225
|
+
* @param {any} S
|
|
226
|
+
* @returns {string}
|
|
227
|
+
*/
|
|
228
|
+
function serializeDocument(S) {
|
|
229
|
+
if (S.sourceFormat === "md") {
|
|
230
|
+
return jxDocToMd(S.document);
|
|
231
|
+
}
|
|
232
|
+
if (S.mode === "content") {
|
|
233
|
+
const mdast = jxToMd(S.document);
|
|
234
|
+
const md = unified()
|
|
235
|
+
.use(remarkDirective)
|
|
236
|
+
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
|
237
|
+
.stringify(mdast);
|
|
238
|
+
const fm = S.content?.frontmatter;
|
|
239
|
+
const hasFrontmatter = fm && Object.keys(fm).length > 0;
|
|
240
|
+
return hasFrontmatter ? `---\n${stringifyYaml(fm).trim()}\n---\n\n${md}` : md;
|
|
194
241
|
}
|
|
242
|
+
return JSON.stringify(S.document, null, 2);
|
|
195
243
|
}
|
package/src/files/files.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { html, render as litRender, nothing } from "lit-html";
|
|
9
9
|
import { unified } from "unified";
|
|
10
10
|
import remarkStringify from "remark-stringify";
|
|
11
|
+
import remarkDirective from "remark-directive";
|
|
11
12
|
import { stringify as stringifyYaml } from "yaml";
|
|
12
13
|
import { jxToMd } from "../markdown/md-convert.js";
|
|
13
14
|
import { createState, projectState, setProjectState } from "../store.js";
|
|
@@ -105,14 +106,26 @@ export async function openProject({ S, commit, renderActivityBar, renderLeftPane
|
|
|
105
106
|
await loadDirectory(".");
|
|
106
107
|
await loadComponentRegistry();
|
|
107
108
|
|
|
108
|
-
// Auto-expand key directories
|
|
109
|
+
// Auto-expand key directories and populate projectDirs for Browse view
|
|
110
|
+
const conventionalDirs = [
|
|
111
|
+
"pages",
|
|
112
|
+
"layouts",
|
|
113
|
+
"components",
|
|
114
|
+
"content",
|
|
115
|
+
"data",
|
|
116
|
+
"public",
|
|
117
|
+
"styles",
|
|
118
|
+
];
|
|
109
119
|
const entries = projectState.dirs.get(".") || [];
|
|
120
|
+
const foundDirs = [];
|
|
110
121
|
for (const e of entries) {
|
|
111
|
-
if (e.type === "directory" &&
|
|
122
|
+
if (e.type === "directory" && conventionalDirs.includes(e.name)) {
|
|
123
|
+
foundDirs.push(e.name);
|
|
112
124
|
projectState.expanded.add(e.path || e.name);
|
|
113
125
|
await loadDirectory(e.path || e.name);
|
|
114
126
|
}
|
|
115
127
|
}
|
|
128
|
+
projectState.projectDirs = foundDirs;
|
|
116
129
|
|
|
117
130
|
commit({ ...S, ui: { ...S.ui, leftTab: "files" } });
|
|
118
131
|
renderActivityBar();
|
|
@@ -468,8 +481,9 @@ async function createNewFile(dirPath = ".", /** @type {() => void} */ renderLeft
|
|
|
468
481
|
async function renameFile(/** @type {any} */ entry, /** @type {() => void} */ renderLeftPanel) {
|
|
469
482
|
const newName = prompt("New name:", entry.name);
|
|
470
483
|
if (!newName || newName === entry.name) return;
|
|
471
|
-
const
|
|
472
|
-
|
|
484
|
+
const entryPath = entry.path.replaceAll("\\", "/");
|
|
485
|
+
const parentDir = entryPath.includes("/")
|
|
486
|
+
? entryPath.substring(0, entryPath.lastIndexOf("/"))
|
|
473
487
|
: ".";
|
|
474
488
|
const newPath = parentDir === "." ? newName : `${parentDir}/${newName}`;
|
|
475
489
|
try {
|
|
@@ -491,9 +505,8 @@ async function deleteFile(/** @type {any} */ entry, /** @type {() => void} */ re
|
|
|
491
505
|
try {
|
|
492
506
|
const platform = getPlatform();
|
|
493
507
|
await platform.deleteFile(entry.path);
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
: ".";
|
|
508
|
+
const delPath = entry.path.replaceAll("\\", "/");
|
|
509
|
+
const parentDir = delPath.includes("/") ? delPath.substring(0, delPath.lastIndexOf("/")) : ".";
|
|
497
510
|
await loadDirectory(parentDir);
|
|
498
511
|
if (projectState.selectedPath === entry.path) {
|
|
499
512
|
projectState.selectedPath = null;
|
|
@@ -528,6 +541,7 @@ export async function openFileFromTree(ctx, path) {
|
|
|
528
541
|
if (isContent) {
|
|
529
542
|
const mdast = jxToMd(ctx.S.document);
|
|
530
543
|
const md = unified()
|
|
544
|
+
.use(remarkDirective)
|
|
531
545
|
.use(remarkStringify, { bullet: "-", emphasis: "*", strong: "*" })
|
|
532
546
|
.stringify(mdast);
|
|
533
547
|
const fm = ctx.S.content?.frontmatter;
|
|
@@ -548,7 +562,7 @@ export async function openFileFromTree(ctx, path) {
|
|
|
548
562
|
if (!content) return;
|
|
549
563
|
|
|
550
564
|
if (path.endsWith(".md")) {
|
|
551
|
-
ctx.loadMarkdown(content, null);
|
|
565
|
+
await ctx.loadMarkdown(content, null);
|
|
552
566
|
ctx.S.documentPath = path;
|
|
553
567
|
} else {
|
|
554
568
|
const doc = JSON.parse(content);
|