starmark 1.0.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/bin/starmark.js +5 -0
- package/package.json +43 -0
- package/public/app.js +2952 -0
- package/public/frontmatter-editor.js +490 -0
- package/public/frontmatter.js +554 -0
- package/public/icons.js +52 -0
- package/public/index.html +113 -0
- package/public/styles.css +1564 -0
- package/public/toolkit.js +19 -0
- package/public/tools/10-undo.js +26 -0
- package/public/tools/11-redo.js +26 -0
- package/public/tools/20-bold.js +21 -0
- package/public/tools/21-italic.js +21 -0
- package/public/tools/22-strikethrough.js +21 -0
- package/public/tools/30-link.js +136 -0
- package/public/tools/31-image.js +488 -0
- package/src/content-config.js +391 -0
- package/src/server.js +1056 -0
package/public/app.js
ADDED
|
@@ -0,0 +1,2952 @@
|
|
|
1
|
+
import { icons } from "./icons.js";
|
|
2
|
+
import { createFrontmatterEditor } from "./frontmatter-editor.js";
|
|
3
|
+
import { normalizeFrontmatter } from "./frontmatter.js";
|
|
4
|
+
|
|
5
|
+
const folderInput = document.getElementById("folder-path");
|
|
6
|
+
const browseBtn = document.getElementById("browse-btn");
|
|
7
|
+
const scanBtn = document.getElementById("scan-btn");
|
|
8
|
+
const scanInfo = document.getElementById("scan-info");
|
|
9
|
+
const fileCount = document.getElementById("file-count");
|
|
10
|
+
const collapseAllBtn = document.getElementById("collapse-all-btn");
|
|
11
|
+
const emptyState = document.getElementById("empty-state");
|
|
12
|
+
const searchBox = document.getElementById("search-box");
|
|
13
|
+
const fileSearch = document.getElementById("file-search");
|
|
14
|
+
const fileList = document.getElementById("file-list");
|
|
15
|
+
const listView = document.getElementById("list-view");
|
|
16
|
+
const editView = document.getElementById("edit-view");
|
|
17
|
+
const editBackBtn = document.getElementById("edit-back-btn");
|
|
18
|
+
const editSaveBtn = document.getElementById("edit-save-btn");
|
|
19
|
+
const editFileName = document.getElementById("edit-file-name");
|
|
20
|
+
const editFilePath = document.getElementById("edit-file-path");
|
|
21
|
+
const markdownEditor = document.getElementById("markdown-editor");
|
|
22
|
+
const editToolbar = document.getElementById("edit-toolbar");
|
|
23
|
+
const frontmatterPanel = document.getElementById("frontmatter-panel");
|
|
24
|
+
const frontmatterEditorRoot = document.getElementById("frontmatter-editor");
|
|
25
|
+
const frontmatterEditor = createFrontmatterEditor(frontmatterEditorRoot, {
|
|
26
|
+
onChange(value, options = {}) {
|
|
27
|
+
handleFrontmatterInput();
|
|
28
|
+
if (options.save) {
|
|
29
|
+
saveCurrentFile();
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
const projectsSection = document.getElementById("projects-section");
|
|
34
|
+
const projectList = document.getElementById("project-list");
|
|
35
|
+
const projectsMenuBtn = document.getElementById("projects-menu-btn");
|
|
36
|
+
const projectsDialog = document.getElementById("projects-dialog");
|
|
37
|
+
const projectsDialogClose = document.getElementById("projects-dialog-close");
|
|
38
|
+
const topBar = document.querySelector(".top-bar");
|
|
39
|
+
|
|
40
|
+
let projectButtons = [];
|
|
41
|
+
let scannedFiles = [];
|
|
42
|
+
let scannedDirectories = [];
|
|
43
|
+
let lastScanTargets = [];
|
|
44
|
+
let currentProjectPath = "";
|
|
45
|
+
let expandedPaths = new Set();
|
|
46
|
+
let hasStoredExpandedPaths = false;
|
|
47
|
+
let currentEditFile = null;
|
|
48
|
+
let currentEditFrontmatter = null;
|
|
49
|
+
let isSavingFile = false;
|
|
50
|
+
let saveButtonResetTimeout = null;
|
|
51
|
+
let pendingEditorCaret = null;
|
|
52
|
+
let editorHistory = [];
|
|
53
|
+
let editorHistoryIndex = -1;
|
|
54
|
+
let editorHistoryDebounce = null;
|
|
55
|
+
let isApplyingEditorHistory = false;
|
|
56
|
+
const historyChangeListeners = [];
|
|
57
|
+
|
|
58
|
+
const HISTORY_DEBOUNCE_MS = 400;
|
|
59
|
+
const MAX_EDITOR_HISTORY = 100;
|
|
60
|
+
|
|
61
|
+
const SOURCE_ROOTS = {
|
|
62
|
+
content: "src/content",
|
|
63
|
+
pages: "src/pages",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const HEADING_LINE_RE = /^(#{1,6})\s+(.*)$/;
|
|
67
|
+
const CODE_FENCE_RE = /^```/;
|
|
68
|
+
const CODE_FENCE_CLOSE_RE = /^```\s*$/;
|
|
69
|
+
const BLOCKQUOTE_LINE_RE = /^\s*>/;
|
|
70
|
+
const UNORDERED_LIST_LINE_RE = /^\s*[-*+]\s/;
|
|
71
|
+
const ORDERED_LIST_LINE_RE = /^\s*\d+\.\s/;
|
|
72
|
+
const COLON_BLOCK_OPEN_RE = /^(:{3,})/;
|
|
73
|
+
const COLON_INLINE_DOUBLE_RE = /^::(?!\:)/;
|
|
74
|
+
const COLON_INLINE_SINGLE_RE = /^:(?!\:)/;
|
|
75
|
+
const EDITOR_LINE_SELECTOR = "p, h1, h2, h3, h4, h5, h6";
|
|
76
|
+
const COLON_DEPTH_CLASSES = Array.from({ length: 6 }, (_, index) => `colon-depth-${index + 1}`);
|
|
77
|
+
const LINE_DECORATION_CLASSES = [
|
|
78
|
+
"is-code-block",
|
|
79
|
+
"is-blockquote",
|
|
80
|
+
"is-list",
|
|
81
|
+
"is-colon-block-start",
|
|
82
|
+
"is-colon-block-end",
|
|
83
|
+
"is-colon-inline",
|
|
84
|
+
...COLON_DEPTH_CLASSES,
|
|
85
|
+
];
|
|
86
|
+
const COLON_IMG_LINE_RE = /^:img\{\s*([^}]*)\}\s*$/;
|
|
87
|
+
const HTML_IMG_LINE_RE = /^<img\b[^>]*\/?>\s*$/i;
|
|
88
|
+
const COLON_IMG_SRC_RE = /\bsrc\s*=\s*"([^"]*)"/;
|
|
89
|
+
const COLON_IMG_ALT_RE = /\balt\s*=\s*"([^"]*)"/;
|
|
90
|
+
const MARKDOWN_IMG_RE = /!\[([^\]]*)\]\(([^)]+)\)/g;
|
|
91
|
+
|
|
92
|
+
function getCodeBlockStates(lines) {
|
|
93
|
+
const states = [];
|
|
94
|
+
let inCodeBlock = false;
|
|
95
|
+
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
if (inCodeBlock) {
|
|
98
|
+
states.push(true);
|
|
99
|
+
|
|
100
|
+
if (CODE_FENCE_CLOSE_RE.test(line)) {
|
|
101
|
+
inCodeBlock = false;
|
|
102
|
+
}
|
|
103
|
+
} else if (CODE_FENCE_RE.test(line)) {
|
|
104
|
+
states.push(true);
|
|
105
|
+
inCodeBlock = true;
|
|
106
|
+
} else {
|
|
107
|
+
states.push(false);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return states;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getColonLineStates(lines, codeBlockStates) {
|
|
115
|
+
const states = [];
|
|
116
|
+
const blockStack = [];
|
|
117
|
+
|
|
118
|
+
function pushContentState() {
|
|
119
|
+
states.push({
|
|
120
|
+
inColonBlock: blockStack.length > 0,
|
|
121
|
+
colonRole: null,
|
|
122
|
+
colonDepth: 0,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function pushInlineState(depth) {
|
|
127
|
+
states.push({
|
|
128
|
+
inColonBlock: blockStack.length > 0,
|
|
129
|
+
colonRole: "inline",
|
|
130
|
+
colonDepth: depth,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
135
|
+
const line = lines[index];
|
|
136
|
+
|
|
137
|
+
if (codeBlockStates[index]) {
|
|
138
|
+
states.push({
|
|
139
|
+
inColonBlock: blockStack.length > 0,
|
|
140
|
+
colonRole: null,
|
|
141
|
+
colonDepth: 0,
|
|
142
|
+
});
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (blockStack.length > 0) {
|
|
147
|
+
const currentDepth = blockStack[blockStack.length - 1];
|
|
148
|
+
const closePattern = new RegExp(`^(:{${currentDepth}})\\s*$`);
|
|
149
|
+
|
|
150
|
+
if (closePattern.test(line)) {
|
|
151
|
+
states.push({
|
|
152
|
+
inColonBlock: true,
|
|
153
|
+
colonRole: "block-end",
|
|
154
|
+
colonDepth: currentDepth,
|
|
155
|
+
});
|
|
156
|
+
blockStack.pop();
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const nestedOpenMatch = line.match(COLON_BLOCK_OPEN_RE);
|
|
161
|
+
if (nestedOpenMatch) {
|
|
162
|
+
const depth = nestedOpenMatch[1].length;
|
|
163
|
+
states.push({
|
|
164
|
+
inColonBlock: true,
|
|
165
|
+
colonRole: "block-start",
|
|
166
|
+
colonDepth: depth,
|
|
167
|
+
});
|
|
168
|
+
blockStack.push(depth);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (COLON_INLINE_DOUBLE_RE.test(line)) {
|
|
173
|
+
pushInlineState(2);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (COLON_INLINE_SINGLE_RE.test(line)) {
|
|
178
|
+
pushInlineState(1);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
pushContentState();
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const blockOpenMatch = line.match(COLON_BLOCK_OPEN_RE);
|
|
187
|
+
if (blockOpenMatch) {
|
|
188
|
+
const depth = blockOpenMatch[1].length;
|
|
189
|
+
states.push({
|
|
190
|
+
inColonBlock: true,
|
|
191
|
+
colonRole: "block-start",
|
|
192
|
+
colonDepth: depth,
|
|
193
|
+
});
|
|
194
|
+
blockStack.push(depth);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (COLON_INLINE_DOUBLE_RE.test(line)) {
|
|
199
|
+
pushInlineState(2);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (COLON_INLINE_SINGLE_RE.test(line)) {
|
|
204
|
+
pushInlineState(1);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pushContentState();
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return states;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function getEditorLineStates(lines) {
|
|
215
|
+
const codeBlockStates = getCodeBlockStates(lines);
|
|
216
|
+
const colonStates = getColonLineStates(lines, codeBlockStates);
|
|
217
|
+
|
|
218
|
+
return lines.map((line, index) => {
|
|
219
|
+
const inCodeBlock = codeBlockStates[index];
|
|
220
|
+
const isBlockquote = !inCodeBlock && BLOCKQUOTE_LINE_RE.test(line);
|
|
221
|
+
const isList =
|
|
222
|
+
!inCodeBlock && !isBlockquote && (UNORDERED_LIST_LINE_RE.test(line) || ORDERED_LIST_LINE_RE.test(line));
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
inCodeBlock,
|
|
226
|
+
inColonBlock: colonStates[index].inColonBlock,
|
|
227
|
+
colonRole: colonStates[index].colonRole,
|
|
228
|
+
colonDepth: colonStates[index].colonDepth,
|
|
229
|
+
isBlockquote,
|
|
230
|
+
isList,
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function getLineElementTagName(line, lineState = {}) {
|
|
236
|
+
if (lineState.inCodeBlock || lineState.inColonBlock) {
|
|
237
|
+
return "p";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const headingMatch = line.match(HEADING_LINE_RE);
|
|
241
|
+
|
|
242
|
+
if (headingMatch) {
|
|
243
|
+
return `h${headingMatch[1].length}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return "p";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getEditorLineText(element) {
|
|
250
|
+
if (
|
|
251
|
+
element.tagName === "P" &&
|
|
252
|
+
element.childNodes.length === 1 &&
|
|
253
|
+
element.firstChild?.nodeName === "BR"
|
|
254
|
+
) {
|
|
255
|
+
return "";
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return element.textContent ?? "";
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getCaretOffsetInElement(element) {
|
|
262
|
+
const selection = window.getSelection();
|
|
263
|
+
if (!selection || selection.rangeCount === 0) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const range = selection.getRangeAt(0);
|
|
268
|
+
if (!element.contains(range.startContainer)) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const preRange = range.cloneRange();
|
|
273
|
+
preRange.selectNodeContents(element);
|
|
274
|
+
preRange.setEnd(range.startContainer, range.startOffset);
|
|
275
|
+
return preRange.toString().length;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function setCaretOffsetInElement(element, offset) {
|
|
279
|
+
const selection = window.getSelection();
|
|
280
|
+
const range = document.createRange();
|
|
281
|
+
let remaining = offset;
|
|
282
|
+
|
|
283
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
284
|
+
let textNode = walker.nextNode();
|
|
285
|
+
|
|
286
|
+
while (textNode) {
|
|
287
|
+
const length = textNode.textContent.length;
|
|
288
|
+
if (remaining <= length) {
|
|
289
|
+
range.setStart(textNode, remaining);
|
|
290
|
+
range.collapse(true);
|
|
291
|
+
selection.removeAllRanges();
|
|
292
|
+
selection.addRange(range);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
remaining -= length;
|
|
297
|
+
textNode = walker.nextNode();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
range.selectNodeContents(element);
|
|
301
|
+
range.collapse(false);
|
|
302
|
+
selection.removeAllRanges();
|
|
303
|
+
selection.addRange(range);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function syncTopBarHeight() {
|
|
307
|
+
if (topBar) {
|
|
308
|
+
document.documentElement.style.setProperty("--top-bar-height", `${topBar.offsetHeight}px`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
function wrapEditorSelection(wrapper) {
|
|
314
|
+
const selection = window.getSelection();
|
|
315
|
+
if (!selection || selection.rangeCount === 0) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const range = selection.getRangeAt(0);
|
|
320
|
+
if (!markdownEditor.contains(range.commonAncestorContainer) || range.collapsed) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const selectedText = range.toString();
|
|
325
|
+
const markdown = `${wrapper}${selectedText}${wrapper}`;
|
|
326
|
+
|
|
327
|
+
range.deleteContents();
|
|
328
|
+
const textNode = document.createTextNode(markdown);
|
|
329
|
+
range.insertNode(textNode);
|
|
330
|
+
|
|
331
|
+
range.setStartAfter(textNode);
|
|
332
|
+
range.collapse(true);
|
|
333
|
+
selection.removeAllRanges();
|
|
334
|
+
selection.addRange(range);
|
|
335
|
+
|
|
336
|
+
reevaluateMarkdownEditorLines();
|
|
337
|
+
markdownEditor.focus();
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function saveEditorCaret() {
|
|
342
|
+
const selection = window.getSelection();
|
|
343
|
+
if (!selection || selection.rangeCount === 0) {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const range = selection.getRangeAt(0);
|
|
348
|
+
if (!markdownEditor.contains(range.commonAncestorContainer)) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const lineElements = getEditorLineElements();
|
|
353
|
+
|
|
354
|
+
for (let lineIndex = 0; lineIndex < lineElements.length; lineIndex += 1) {
|
|
355
|
+
const element = lineElements[lineIndex];
|
|
356
|
+
if (!element.contains(range.startContainer) && element !== range.startContainer) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const offset = getCaretOffsetInElement(element);
|
|
361
|
+
if (offset !== null) {
|
|
362
|
+
return { lineIndex, offset };
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return { lineIndex: lineElements.length, offset: 0 };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function getEditorLineElements() {
|
|
370
|
+
return [...markdownEditor.children].filter((child) => child.matches(EDITOR_LINE_SELECTOR));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function getEditorLines() {
|
|
374
|
+
return getEditorLineElements().map(getEditorLineText);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getEditorContent() {
|
|
378
|
+
return getEditorLines().join("\n");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function getEditorCaretSnapshot() {
|
|
382
|
+
const selection = window.getSelection();
|
|
383
|
+
if (!selection || selection.rangeCount === 0) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const range = selection.getRangeAt(0);
|
|
388
|
+
if (!markdownEditor.contains(range.commonAncestorContainer)) {
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const lineElements = getEditorLineElements();
|
|
393
|
+
|
|
394
|
+
for (let lineIndex = 0; lineIndex < lineElements.length; lineIndex += 1) {
|
|
395
|
+
const element = lineElements[lineIndex];
|
|
396
|
+
if (!element.contains(range.startContainer) && element !== range.startContainer) {
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const offset = getCaretOffsetInElement(element);
|
|
401
|
+
if (offset !== null) {
|
|
402
|
+
return { lineIndex, offset };
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return { lineIndex: lineElements.length, offset: 0 };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function restoreEditorCaretSnapshot(caret) {
|
|
410
|
+
const lineElements = getEditorLineElements();
|
|
411
|
+
|
|
412
|
+
if (!caret || lineElements.length === 0) {
|
|
413
|
+
markdownEditor.focus();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const element = lineElements[Math.min(caret.lineIndex, lineElements.length - 1)];
|
|
418
|
+
if (!element) {
|
|
419
|
+
markdownEditor.focus();
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const maxOffset = getEditorLineText(element).length;
|
|
424
|
+
setCaretOffsetInElement(element, Math.min(caret.offset, maxOffset));
|
|
425
|
+
markdownEditor.focus();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createEditorHistorySnapshot() {
|
|
429
|
+
return {
|
|
430
|
+
content: getEditorContent(),
|
|
431
|
+
caret: getEditorCaretSnapshot(),
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function notifyHistoryChange() {
|
|
436
|
+
const state = {
|
|
437
|
+
canUndo: editorHistoryIndex > 0,
|
|
438
|
+
canRedo: editorHistoryIndex < editorHistory.length - 1,
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
for (const listener of historyChangeListeners) {
|
|
442
|
+
listener(state);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function updateUndoRedoButtons() {
|
|
447
|
+
notifyHistoryChange();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function resetEditorHistory(content) {
|
|
451
|
+
clearTimeout(editorHistoryDebounce);
|
|
452
|
+
editorHistoryDebounce = null;
|
|
453
|
+
editorHistory = [{ content, caret: null }];
|
|
454
|
+
editorHistoryIndex = 0;
|
|
455
|
+
updateUndoRedoButtons();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function trimEditorHistory() {
|
|
459
|
+
if (editorHistory.length <= MAX_EDITOR_HISTORY) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const overflow = editorHistory.length - MAX_EDITOR_HISTORY;
|
|
464
|
+
editorHistory = editorHistory.slice(overflow);
|
|
465
|
+
editorHistoryIndex = Math.max(0, editorHistoryIndex - overflow);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function commitEditorHistory() {
|
|
469
|
+
if (isApplyingEditorHistory || markdownEditor.dataset.loading || !currentEditFile) {
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const snapshot = createEditorHistorySnapshot();
|
|
474
|
+
const currentSnapshot = editorHistory[editorHistoryIndex];
|
|
475
|
+
|
|
476
|
+
if (currentSnapshot && snapshot.content === currentSnapshot.content) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
editorHistory = editorHistory.slice(0, editorHistoryIndex + 1);
|
|
481
|
+
editorHistory.push(snapshot);
|
|
482
|
+
editorHistoryIndex = editorHistory.length - 1;
|
|
483
|
+
trimEditorHistory();
|
|
484
|
+
updateUndoRedoButtons();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function flushEditorHistory() {
|
|
488
|
+
clearTimeout(editorHistoryDebounce);
|
|
489
|
+
editorHistoryDebounce = null;
|
|
490
|
+
commitEditorHistory();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function scheduleEditorHistoryCommit() {
|
|
494
|
+
clearTimeout(editorHistoryDebounce);
|
|
495
|
+
editorHistoryDebounce = setTimeout(commitEditorHistory, HISTORY_DEBOUNCE_MS);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function applyEditorHistorySnapshot(index) {
|
|
499
|
+
const snapshot = editorHistory[index];
|
|
500
|
+
if (!snapshot) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
isApplyingEditorHistory = true;
|
|
505
|
+
clearTimeout(editorHistoryDebounce);
|
|
506
|
+
editorHistoryDebounce = null;
|
|
507
|
+
renderMarkdownEditor(snapshot.content);
|
|
508
|
+
restoreEditorCaretSnapshot(snapshot.caret);
|
|
509
|
+
editorHistoryIndex = index;
|
|
510
|
+
isApplyingEditorHistory = false;
|
|
511
|
+
updateUndoRedoButtons();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function undoEditorChange() {
|
|
515
|
+
if (editorHistoryIndex <= 0) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
applyEditorHistorySnapshot(editorHistoryIndex - 1);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function redoEditorChange() {
|
|
523
|
+
if (editorHistoryIndex >= editorHistory.length - 1) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
applyEditorHistorySnapshot(editorHistoryIndex + 1);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function runEditorHistoryAction(action) {
|
|
531
|
+
flushEditorHistory();
|
|
532
|
+
action();
|
|
533
|
+
flushEditorHistory();
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function insertMarkdownAtCaret(markdown, caret = pendingEditorCaret) {
|
|
537
|
+
if (!caret) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const lines = getEditorLines();
|
|
542
|
+
const { lineIndex, offset } = caret;
|
|
543
|
+
const currentLine = lines[lineIndex] ?? "";
|
|
544
|
+
const before = currentLine.slice(0, offset);
|
|
545
|
+
const after = currentLine.slice(offset);
|
|
546
|
+
const insertedLines = markdown.split(/\r?\n/);
|
|
547
|
+
|
|
548
|
+
const nextLines = [
|
|
549
|
+
...lines.slice(0, lineIndex),
|
|
550
|
+
before,
|
|
551
|
+
...insertedLines,
|
|
552
|
+
after,
|
|
553
|
+
...lines.slice(lineIndex + 1),
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
renderMarkdownEditor(nextLines.join("\n"));
|
|
557
|
+
|
|
558
|
+
const caretLineIndex = lineIndex + 1 + insertedLines.length - 1;
|
|
559
|
+
const lineElements = getEditorLineElements();
|
|
560
|
+
const caretElement = lineElements[caretLineIndex];
|
|
561
|
+
|
|
562
|
+
if (caretElement) {
|
|
563
|
+
setCaretOffsetInElement(caretElement, insertedLines[insertedLines.length - 1].length);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
pendingEditorCaret = null;
|
|
567
|
+
flushEditorHistory();
|
|
568
|
+
markdownEditor.focus();
|
|
569
|
+
return true;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function applyLineDecorations(element, lineState) {
|
|
573
|
+
element.classList.toggle("is-code-block", lineState.inCodeBlock);
|
|
574
|
+
element.classList.toggle("is-blockquote", lineState.isBlockquote);
|
|
575
|
+
element.classList.toggle("is-list", lineState.isList);
|
|
576
|
+
element.classList.toggle("is-colon-block-start", lineState.colonRole === "block-start");
|
|
577
|
+
element.classList.toggle("is-colon-block-end", lineState.colonRole === "block-end");
|
|
578
|
+
element.classList.toggle("is-colon-inline", lineState.colonRole === "inline");
|
|
579
|
+
|
|
580
|
+
const depthClass =
|
|
581
|
+
lineState.colonRole && lineState.colonDepth > 0
|
|
582
|
+
? `colon-depth-${Math.min(lineState.colonDepth, COLON_DEPTH_CLASSES.length)}`
|
|
583
|
+
: null;
|
|
584
|
+
|
|
585
|
+
for (const className of COLON_DEPTH_CLASSES) {
|
|
586
|
+
element.classList.toggle(className, className === depthClass);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parseColonImgAttributes(attributeString) {
|
|
591
|
+
return {
|
|
592
|
+
src: attributeString.match(COLON_IMG_SRC_RE)?.[1] ?? "",
|
|
593
|
+
alt: attributeString.match(COLON_IMG_ALT_RE)?.[1] ?? "",
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function resolveEditorImageUrl(src) {
|
|
598
|
+
const trimmedSrc = src.trim();
|
|
599
|
+
if (!trimmedSrc) {
|
|
600
|
+
return "";
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (/^https?:\/\//i.test(trimmedSrc) || /^data:/i.test(trimmedSrc)) {
|
|
604
|
+
return trimmedSrc;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (!currentProjectPath) {
|
|
608
|
+
return trimmedSrc;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const relativePath = trimmedSrc.replace(/^\//, "");
|
|
612
|
+
return `/api/media/file?project=${encodeURIComponent(currentProjectPath)}&path=${encodeURIComponent(relativePath)}`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function getExpectedImagePreviews(line, lineState = {}) {
|
|
616
|
+
if (lineState.inCodeBlock || line.length === 0) {
|
|
617
|
+
return [];
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const colonImgMatch = line.match(COLON_IMG_LINE_RE);
|
|
621
|
+
if (colonImgMatch) {
|
|
622
|
+
const attributes = parseColonImgAttributes(colonImgMatch[1]);
|
|
623
|
+
return attributes.src ? [attributes] : [];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (HTML_IMG_LINE_RE.test(line)) {
|
|
627
|
+
const attributes = parseColonImgAttributes(line);
|
|
628
|
+
return attributes.src ? [attributes] : [];
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const previews = [];
|
|
632
|
+
MARKDOWN_IMG_RE.lastIndex = 0;
|
|
633
|
+
let match = MARKDOWN_IMG_RE.exec(line);
|
|
634
|
+
while (match) {
|
|
635
|
+
previews.push({ src: match[2], alt: match[1] });
|
|
636
|
+
match = MARKDOWN_IMG_RE.exec(line);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return previews;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function createEditorImagePreviewChip({ src, alt }) {
|
|
643
|
+
const preview = document.createElement("span");
|
|
644
|
+
preview.className = "editor-image-preview";
|
|
645
|
+
preview.contentEditable = "false";
|
|
646
|
+
preview.dataset.src = src;
|
|
647
|
+
preview.dataset.alt = alt ?? "";
|
|
648
|
+
preview.style.setProperty("--editor-image-url", `url("${resolveEditorImageUrl(src)}")`);
|
|
649
|
+
preview.setAttribute("role", "button");
|
|
650
|
+
preview.setAttribute("tabindex", "-1");
|
|
651
|
+
preview.setAttribute(
|
|
652
|
+
"aria-label",
|
|
653
|
+
alt ? `Preview image: ${alt}. Click to enlarge.` : "Preview image. Click to enlarge.",
|
|
654
|
+
);
|
|
655
|
+
return preview;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function appendEditorLineContent(element, line, lineState = {}) {
|
|
659
|
+
if (line.length === 0) {
|
|
660
|
+
element.append(document.createElement("br"));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (lineState.inCodeBlock) {
|
|
665
|
+
element.textContent = line;
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const colonImgMatch = line.match(COLON_IMG_LINE_RE);
|
|
670
|
+
if (colonImgMatch) {
|
|
671
|
+
element.append(document.createTextNode(line));
|
|
672
|
+
const attributes = parseColonImgAttributes(colonImgMatch[1]);
|
|
673
|
+
if (attributes.src) {
|
|
674
|
+
element.append(createEditorImagePreviewChip(attributes));
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (HTML_IMG_LINE_RE.test(line)) {
|
|
680
|
+
element.append(document.createTextNode(line));
|
|
681
|
+
const attributes = parseColonImgAttributes(line);
|
|
682
|
+
if (attributes.src) {
|
|
683
|
+
element.append(createEditorImagePreviewChip(attributes));
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const previews = getExpectedImagePreviews(line, lineState);
|
|
689
|
+
if (previews.length === 0) {
|
|
690
|
+
element.textContent = line;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
let lastIndex = 0;
|
|
695
|
+
MARKDOWN_IMG_RE.lastIndex = 0;
|
|
696
|
+
let match = MARKDOWN_IMG_RE.exec(line);
|
|
697
|
+
while (match) {
|
|
698
|
+
if (match.index > lastIndex) {
|
|
699
|
+
element.append(document.createTextNode(line.slice(lastIndex, match.index)));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
element.append(document.createTextNode(match[0]));
|
|
703
|
+
element.append(createEditorImagePreviewChip({ src: match[2], alt: match[1] }));
|
|
704
|
+
lastIndex = match.index + match[0].length;
|
|
705
|
+
match = MARKDOWN_IMG_RE.exec(line);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (lastIndex < line.length) {
|
|
709
|
+
element.append(document.createTextNode(line.slice(lastIndex)));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function lineInlineDecorationsMatch(element, line, lineState = {}) {
|
|
714
|
+
const expectedPreviews = getExpectedImagePreviews(line, lineState);
|
|
715
|
+
const actualPreviews = [...element.querySelectorAll(".editor-image-preview")].map((preview) => ({
|
|
716
|
+
src: preview.dataset.src ?? "",
|
|
717
|
+
alt: preview.dataset.alt ?? "",
|
|
718
|
+
}));
|
|
719
|
+
|
|
720
|
+
if (expectedPreviews.length !== actualPreviews.length) {
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return expectedPreviews.every(
|
|
725
|
+
(preview, index) =>
|
|
726
|
+
preview.src === actualPreviews[index].src && preview.alt === actualPreviews[index].alt,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function refreshEditorLineInlineDecorations(element, line, lineState = {}) {
|
|
731
|
+
const caretOffset = getCaretOffsetInElement(element);
|
|
732
|
+
element.replaceChildren();
|
|
733
|
+
appendEditorLineContent(element, line, lineState);
|
|
734
|
+
applyLineDecorations(element, lineState);
|
|
735
|
+
|
|
736
|
+
if (caretOffset !== null) {
|
|
737
|
+
setCaretOffsetInElement(element, caretOffset);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function lineDecorationsMatch(element, lineState) {
|
|
742
|
+
for (const className of LINE_DECORATION_CLASSES) {
|
|
743
|
+
const shouldHaveClass =
|
|
744
|
+
(className === "is-code-block" && lineState.inCodeBlock) ||
|
|
745
|
+
(className === "is-blockquote" && lineState.isBlockquote) ||
|
|
746
|
+
(className === "is-list" && lineState.isList) ||
|
|
747
|
+
(className === "is-colon-block-start" && lineState.colonRole === "block-start") ||
|
|
748
|
+
(className === "is-colon-block-end" && lineState.colonRole === "block-end") ||
|
|
749
|
+
(className === "is-colon-inline" && lineState.colonRole === "inline") ||
|
|
750
|
+
(lineState.colonRole &&
|
|
751
|
+
lineState.colonDepth > 0 &&
|
|
752
|
+
className ===
|
|
753
|
+
`colon-depth-${Math.min(lineState.colonDepth, COLON_DEPTH_CLASSES.length)}`);
|
|
754
|
+
|
|
755
|
+
if (element.classList.contains(className) !== shouldHaveClass) {
|
|
756
|
+
return false;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function createEditorLineElement(line, lineState = {}) {
|
|
764
|
+
const tagName = getLineElementTagName(line, lineState);
|
|
765
|
+
|
|
766
|
+
if (tagName !== "p") {
|
|
767
|
+
const element = document.createElement(tagName);
|
|
768
|
+
element.textContent = line;
|
|
769
|
+
return element;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const paragraph = document.createElement("p");
|
|
773
|
+
appendEditorLineContent(paragraph, line, lineState);
|
|
774
|
+
applyLineDecorations(paragraph, lineState);
|
|
775
|
+
|
|
776
|
+
return paragraph;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function reevaluateEditorLine(element, lineState = {}) {
|
|
780
|
+
const line = getEditorLineText(element);
|
|
781
|
+
const expectedTag = getLineElementTagName(line, lineState);
|
|
782
|
+
const tagMatches = element.tagName.toLowerCase() === expectedTag;
|
|
783
|
+
const decorationsMatch = lineDecorationsMatch(element, lineState);
|
|
784
|
+
|
|
785
|
+
if (tagMatches && decorationsMatch) {
|
|
786
|
+
if (!lineInlineDecorationsMatch(element, line, lineState)) {
|
|
787
|
+
refreshEditorLineInlineDecorations(element, line, lineState);
|
|
788
|
+
}
|
|
789
|
+
return element;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
if (tagMatches) {
|
|
793
|
+
applyLineDecorations(element, lineState);
|
|
794
|
+
return element;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const caretOffset = getCaretOffsetInElement(element);
|
|
798
|
+
const nextElement = createEditorLineElement(line, lineState);
|
|
799
|
+
element.replaceWith(nextElement);
|
|
800
|
+
|
|
801
|
+
if (caretOffset !== null) {
|
|
802
|
+
setCaretOffsetInElement(nextElement, caretOffset);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
return nextElement;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function reevaluateMarkdownEditorLines() {
|
|
809
|
+
if (markdownEditor.dataset.loading || !currentEditFile) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const lineElements = [...markdownEditor.children].filter((child) =>
|
|
814
|
+
child.matches(EDITOR_LINE_SELECTOR),
|
|
815
|
+
);
|
|
816
|
+
const lineStates = getEditorLineStates(lineElements.map(getEditorLineText));
|
|
817
|
+
|
|
818
|
+
lineElements.forEach((child, index) => {
|
|
819
|
+
reevaluateEditorLine(child, lineStates[index]);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
markdownEditor.classList.toggle("is-empty", markdownEditor.childElementCount === 0);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function renderMarkdownEditor(content) {
|
|
826
|
+
markdownEditor.replaceChildren();
|
|
827
|
+
|
|
828
|
+
const lines = content.split(/\r?\n/);
|
|
829
|
+
const lineStates = getEditorLineStates(lines);
|
|
830
|
+
|
|
831
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
832
|
+
markdownEditor.append(createEditorLineElement(lines[index], lineStates[index]));
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
markdownEditor.classList.toggle("is-empty", markdownEditor.childElementCount === 0);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function setMarkdownEditorMessage(message) {
|
|
839
|
+
markdownEditor.replaceChildren();
|
|
840
|
+
const paragraph = document.createElement("p");
|
|
841
|
+
paragraph.textContent = message;
|
|
842
|
+
markdownEditor.append(paragraph);
|
|
843
|
+
markdownEditor.classList.remove("is-empty");
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function formatFileCount(count, total = count) {
|
|
847
|
+
const countLabel = count === 1 ? "1 file" : `${count} files`;
|
|
848
|
+
if (count === total) {
|
|
849
|
+
return countLabel;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const totalLabel = total === 1 ? "1 file" : `${total} files`;
|
|
853
|
+
return `${countLabel} of ${totalLabel}`;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function filterFiles(files, query) {
|
|
857
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
858
|
+
if (!normalizedQuery) {
|
|
859
|
+
return files;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return files.filter((file) => {
|
|
863
|
+
const haystack = [
|
|
864
|
+
file.name,
|
|
865
|
+
file.relativePath,
|
|
866
|
+
file.source,
|
|
867
|
+
file.extension,
|
|
868
|
+
]
|
|
869
|
+
.join(" ")
|
|
870
|
+
.toLowerCase();
|
|
871
|
+
|
|
872
|
+
return haystack.includes(normalizedQuery);
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function loadExpandedPaths(projectPath) {
|
|
877
|
+
expandedPaths = new Set();
|
|
878
|
+
hasStoredExpandedPaths = false;
|
|
879
|
+
|
|
880
|
+
if (!projectPath) {
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
try {
|
|
885
|
+
const stored = localStorage.getItem(`starmark:expanded:${projectPath}`);
|
|
886
|
+
if (stored !== null) {
|
|
887
|
+
hasStoredExpandedPaths = true;
|
|
888
|
+
expandedPaths = new Set(JSON.parse(stored));
|
|
889
|
+
}
|
|
890
|
+
} catch {
|
|
891
|
+
expandedPaths = new Set();
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function saveExpandedPaths() {
|
|
896
|
+
if (!currentProjectPath) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
hasStoredExpandedPaths = true;
|
|
901
|
+
|
|
902
|
+
try {
|
|
903
|
+
localStorage.setItem(
|
|
904
|
+
`starmark:expanded:${currentProjectPath}`,
|
|
905
|
+
JSON.stringify([...expandedPaths]),
|
|
906
|
+
);
|
|
907
|
+
} catch {
|
|
908
|
+
// ignore storage failures
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function ensureDefaultExpandedRoots(tree) {
|
|
913
|
+
if (hasStoredExpandedPaths || expandedPaths.size > 0) {
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
for (const node of tree) {
|
|
918
|
+
if (node.type === "folder" || node.type === "page-folder") {
|
|
919
|
+
expandedPaths.add(node.path);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function collectFolderPaths(nodes, paths = []) {
|
|
925
|
+
for (const node of nodes) {
|
|
926
|
+
if (node.type !== "folder" && node.type !== "page-folder") {
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
paths.push(node.path);
|
|
931
|
+
|
|
932
|
+
if (node.children.length > 0) {
|
|
933
|
+
collectFolderPaths(node.children, paths);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
return paths;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function collapseAllFolders() {
|
|
941
|
+
expandedPaths.clear();
|
|
942
|
+
hasStoredExpandedPaths = true;
|
|
943
|
+
saveExpandedPaths();
|
|
944
|
+
updateFileResults();
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function countTreeContents(nodes) {
|
|
948
|
+
let folders = 0;
|
|
949
|
+
let files = 0;
|
|
950
|
+
|
|
951
|
+
for (const node of nodes) {
|
|
952
|
+
if (node.type === "folder" || node.type === "page-folder") {
|
|
953
|
+
folders += 1;
|
|
954
|
+
const nested = countTreeContents(node.children);
|
|
955
|
+
folders += nested.folders;
|
|
956
|
+
files += nested.files;
|
|
957
|
+
} else {
|
|
958
|
+
files += 1;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return { folders, files };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function resolveTreePath(relativePath, sourceHint) {
|
|
966
|
+
const normalized = String(relativePath).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
967
|
+
|
|
968
|
+
if (normalized === "src/content" || normalized.startsWith("src/content/")) {
|
|
969
|
+
return {
|
|
970
|
+
source: sourceHint ?? "content",
|
|
971
|
+
rootPath: SOURCE_ROOTS.content,
|
|
972
|
+
segments:
|
|
973
|
+
normalized === "src/content"
|
|
974
|
+
? []
|
|
975
|
+
: normalized.slice(`${SOURCE_ROOTS.content}/`.length).split("/").filter(Boolean),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (normalized === "src/pages" || normalized.startsWith("src/pages/")) {
|
|
980
|
+
return {
|
|
981
|
+
source: sourceHint ?? "pages",
|
|
982
|
+
rootPath: SOURCE_ROOTS.pages,
|
|
983
|
+
segments:
|
|
984
|
+
normalized === "src/pages"
|
|
985
|
+
? []
|
|
986
|
+
: normalized.slice(`${SOURCE_ROOTS.pages}/`.length).split("/").filter(Boolean),
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return {
|
|
991
|
+
source: sourceHint ?? "project",
|
|
992
|
+
rootPath: "",
|
|
993
|
+
segments: normalized.split("/").filter(Boolean),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function isIndexFile(name) {
|
|
998
|
+
const lower = name.toLowerCase();
|
|
999
|
+
return lower === "index.md" || lower === "index.mdx";
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function parseNavOrderFromFrontmatter(frontmatter) {
|
|
1003
|
+
if (!frontmatter) {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
const match = frontmatter.match(/^navOrder:\s*(.+)$/m);
|
|
1008
|
+
if (!match) {
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
let value = match[1].trim();
|
|
1013
|
+
if (
|
|
1014
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
1015
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
1016
|
+
) {
|
|
1017
|
+
value = value.slice(1, -1);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const parsed = Number(value);
|
|
1021
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function getPageStemFromFileName(name) {
|
|
1025
|
+
const lower = name.toLowerCase();
|
|
1026
|
+
if (lower.endsWith(".mdx")) {
|
|
1027
|
+
return name.slice(0, -4);
|
|
1028
|
+
}
|
|
1029
|
+
if (lower.endsWith(".md")) {
|
|
1030
|
+
return name.slice(0, -3);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function getSortableNavOrder(node) {
|
|
1037
|
+
if (node.type === "page-folder") {
|
|
1038
|
+
return node.pageFile.navOrder ?? Number.MAX_SAFE_INTEGER;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return node.file?.navOrder ?? Number.MAX_SAFE_INTEGER;
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function getSortableIndexName(node) {
|
|
1045
|
+
if (node.type === "page-folder") {
|
|
1046
|
+
return node.pageFile.name;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return node.name;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function getSortableDisplayName(node) {
|
|
1053
|
+
if (node.type === "page-folder") {
|
|
1054
|
+
return node.name;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
return node.name;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function compareFolderItems(a, b) {
|
|
1061
|
+
const aIsIndex = isIndexFile(getSortableIndexName(a));
|
|
1062
|
+
const bIsIndex = isIndexFile(getSortableIndexName(b));
|
|
1063
|
+
if (aIsIndex !== bIsIndex) {
|
|
1064
|
+
return aIsIndex ? -1 : 1;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const aOrder = getSortableNavOrder(a);
|
|
1068
|
+
const bOrder = getSortableNavOrder(b);
|
|
1069
|
+
if (aOrder !== bOrder) {
|
|
1070
|
+
return aOrder - bOrder;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
return getSortableDisplayName(a).localeCompare(getSortableDisplayName(b));
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function compareFolderFiles(a, b) {
|
|
1077
|
+
return compareFolderItems(a, b);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function getParentRelativePath(relativePath) {
|
|
1081
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
1082
|
+
const lastSlash = normalized.lastIndexOf("/");
|
|
1083
|
+
return lastSlash === -1 ? "" : normalized.slice(0, lastSlash);
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function toSortableFile(file) {
|
|
1087
|
+
return {
|
|
1088
|
+
type: "file",
|
|
1089
|
+
name: file.name,
|
|
1090
|
+
path: file.relativePath,
|
|
1091
|
+
file,
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function getFolderSiblings(relativePath) {
|
|
1096
|
+
const parentPath = getParentRelativePath(relativePath);
|
|
1097
|
+
|
|
1098
|
+
return scannedFiles
|
|
1099
|
+
.filter((file) => getParentRelativePath(file.relativePath) === parentPath)
|
|
1100
|
+
.map(toSortableFile)
|
|
1101
|
+
.sort(compareFolderFiles);
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function findAdjacentSiblingIndex(siblings, currentIndex, direction) {
|
|
1105
|
+
if (direction === "up") {
|
|
1106
|
+
for (let index = currentIndex - 1; index >= 0; index -= 1) {
|
|
1107
|
+
if (!isIndexFile(siblings[index].name)) {
|
|
1108
|
+
return index;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
return -1;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
for (let index = currentIndex + 1; index < siblings.length; index += 1) {
|
|
1116
|
+
if (!isIndexFile(siblings[index].name)) {
|
|
1117
|
+
return index;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return -1;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function assignNavOrders(siblings) {
|
|
1125
|
+
const assignments = new Map();
|
|
1126
|
+
let order = 0;
|
|
1127
|
+
|
|
1128
|
+
for (const sibling of siblings) {
|
|
1129
|
+
if (isIndexFile(sibling.name)) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
assignments.set(sibling.file.absolutePath, order);
|
|
1134
|
+
order += 1;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
return assignments;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function getMoveState(file) {
|
|
1141
|
+
if (isIndexFile(file.name)) {
|
|
1142
|
+
return { canMoveUp: false, canMoveDown: false };
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const siblings = getFolderSiblings(file.relativePath);
|
|
1146
|
+
const currentIndex = siblings.findIndex(
|
|
1147
|
+
(sibling) => sibling.file.absolutePath === file.absolutePath,
|
|
1148
|
+
);
|
|
1149
|
+
|
|
1150
|
+
if (currentIndex === -1) {
|
|
1151
|
+
return { canMoveUp: false, canMoveDown: false };
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return {
|
|
1155
|
+
canMoveUp: findAdjacentSiblingIndex(siblings, currentIndex, "up") !== -1,
|
|
1156
|
+
canMoveDown: findAdjacentSiblingIndex(siblings, currentIndex, "down") !== -1,
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function setNavOrderInFrontmatter(frontmatter, navOrder) {
|
|
1161
|
+
const line = `navOrder: ${navOrder}`;
|
|
1162
|
+
|
|
1163
|
+
if (!frontmatter?.trim()) {
|
|
1164
|
+
return line;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
if (/^navOrder:\s*.+$/m.test(frontmatter)) {
|
|
1168
|
+
return frontmatter.replace(/^navOrder:\s*.+$/m, line);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return `${frontmatter.replace(/\n?$/, "\n")}${line}`;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
async function persistFileNavOrder(file, navOrder) {
|
|
1175
|
+
let frontmatter;
|
|
1176
|
+
let body;
|
|
1177
|
+
|
|
1178
|
+
if (currentEditFile?.absolutePath === file.absolutePath) {
|
|
1179
|
+
frontmatter = currentEditFrontmatter;
|
|
1180
|
+
body = getEditorContent();
|
|
1181
|
+
} else {
|
|
1182
|
+
const response = await fetch(`/api/file?path=${encodeURIComponent(file.absolutePath)}`);
|
|
1183
|
+
const data = await response.json();
|
|
1184
|
+
|
|
1185
|
+
if (!response.ok) {
|
|
1186
|
+
throw new Error(data.error ?? "Could not read file");
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
frontmatter = data.frontmatter;
|
|
1190
|
+
body = data.content;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const newFrontmatter = setNavOrderInFrontmatter(
|
|
1194
|
+
normalizeFrontmatter(frontmatter),
|
|
1195
|
+
navOrder,
|
|
1196
|
+
);
|
|
1197
|
+
const content = buildFileContent(newFrontmatter, body);
|
|
1198
|
+
const response = await fetch("/api/file", {
|
|
1199
|
+
method: "POST",
|
|
1200
|
+
headers: { "Content-Type": "application/json" },
|
|
1201
|
+
body: JSON.stringify({
|
|
1202
|
+
path: file.absolutePath,
|
|
1203
|
+
content,
|
|
1204
|
+
}),
|
|
1205
|
+
});
|
|
1206
|
+
const data = await response.json();
|
|
1207
|
+
|
|
1208
|
+
if (!response.ok) {
|
|
1209
|
+
throw new Error(data.error ?? "Could not save file");
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
const fileIndex = scannedFiles.findIndex(
|
|
1213
|
+
(entry) => entry.absolutePath === file.absolutePath,
|
|
1214
|
+
);
|
|
1215
|
+
if (fileIndex !== -1) {
|
|
1216
|
+
scannedFiles[fileIndex] = { ...scannedFiles[fileIndex], navOrder };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (currentEditFile?.absolutePath === file.absolutePath) {
|
|
1220
|
+
currentEditFrontmatter = normalizeFrontmatter(newFrontmatter);
|
|
1221
|
+
frontmatterEditor.setValue(currentEditFrontmatter ?? "");
|
|
1222
|
+
updateEditHeader(currentEditFile, currentEditFrontmatter);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async function moveFileInFolder(file, direction) {
|
|
1227
|
+
if (isIndexFile(file.name)) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const siblings = getFolderSiblings(file.relativePath);
|
|
1232
|
+
const currentIndex = siblings.findIndex(
|
|
1233
|
+
(sibling) => sibling.file.absolutePath === file.absolutePath,
|
|
1234
|
+
);
|
|
1235
|
+
const targetIndex = findAdjacentSiblingIndex(siblings, currentIndex, direction);
|
|
1236
|
+
|
|
1237
|
+
if (currentIndex === -1 || targetIndex === -1) {
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const reordered = [...siblings];
|
|
1242
|
+
const [item] = reordered.splice(currentIndex, 1);
|
|
1243
|
+
reordered.splice(targetIndex, 0, item);
|
|
1244
|
+
|
|
1245
|
+
const newOrders = assignNavOrders(reordered);
|
|
1246
|
+
const updates = [];
|
|
1247
|
+
|
|
1248
|
+
for (const [absolutePath, navOrder] of newOrders) {
|
|
1249
|
+
const sibling = reordered.find((entry) => entry.file.absolutePath === absolutePath);
|
|
1250
|
+
const currentNavOrder = sibling?.file.navOrder ?? null;
|
|
1251
|
+
|
|
1252
|
+
if (currentNavOrder !== navOrder) {
|
|
1253
|
+
updates.push({ file: sibling.file, navOrder });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
if (updates.length === 0) {
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
setBusy(true);
|
|
1262
|
+
clearError();
|
|
1263
|
+
|
|
1264
|
+
try {
|
|
1265
|
+
for (const update of updates) {
|
|
1266
|
+
await persistFileNavOrder(update.file, update.navOrder);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
updateFileResults();
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
showError(error.message ?? "Could not reorder files");
|
|
1272
|
+
} finally {
|
|
1273
|
+
setBusy(false);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function buildFileTree(files, { directories = [], scanTargets = [] } = {}) {
|
|
1278
|
+
const roots = new Map();
|
|
1279
|
+
|
|
1280
|
+
function ensureFolder(folderMap, folderPath, name, source) {
|
|
1281
|
+
if (!folderMap.has(folderPath)) {
|
|
1282
|
+
folderMap.set(folderPath, {
|
|
1283
|
+
type: "folder",
|
|
1284
|
+
name,
|
|
1285
|
+
path: folderPath,
|
|
1286
|
+
source,
|
|
1287
|
+
childFolders: new Map(),
|
|
1288
|
+
files: [],
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return folderMap.get(folderPath);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function buildFolderChildren(folder) {
|
|
1296
|
+
const consumedPaths = new Set();
|
|
1297
|
+
const plainSubfolders = [];
|
|
1298
|
+
const pageFolders = [];
|
|
1299
|
+
|
|
1300
|
+
const sortedChildFolders = [...folder.childFolders.values()].sort((a, b) =>
|
|
1301
|
+
a.name.localeCompare(b.name),
|
|
1302
|
+
);
|
|
1303
|
+
|
|
1304
|
+
for (const childFolder of sortedChildFolders) {
|
|
1305
|
+
const matchingFile = folder.files.find((fileEntry) => {
|
|
1306
|
+
const stem = getPageStemFromFileName(fileEntry.name);
|
|
1307
|
+
return stem === childFolder.name;
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
if (matchingFile) {
|
|
1311
|
+
consumedPaths.add(matchingFile.path);
|
|
1312
|
+
pageFolders.push({
|
|
1313
|
+
type: "page-folder",
|
|
1314
|
+
name: childFolder.name,
|
|
1315
|
+
path: childFolder.path,
|
|
1316
|
+
source: childFolder.source,
|
|
1317
|
+
pageFile: matchingFile.file,
|
|
1318
|
+
children: buildFolderChildren(childFolder),
|
|
1319
|
+
});
|
|
1320
|
+
} else {
|
|
1321
|
+
plainSubfolders.push({
|
|
1322
|
+
type: "folder",
|
|
1323
|
+
name: childFolder.name,
|
|
1324
|
+
path: childFolder.path,
|
|
1325
|
+
source: childFolder.source,
|
|
1326
|
+
children: buildFolderChildren(childFolder),
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const remainingFiles = folder.files
|
|
1332
|
+
.filter((fileEntry) => !consumedPaths.has(fileEntry.path))
|
|
1333
|
+
.map((fileEntry) => ({
|
|
1334
|
+
type: "file",
|
|
1335
|
+
name: fileEntry.name,
|
|
1336
|
+
path: fileEntry.path,
|
|
1337
|
+
file: fileEntry.file,
|
|
1338
|
+
}));
|
|
1339
|
+
|
|
1340
|
+
const sortableItems = [...pageFolders, ...remainingFiles].sort(compareFolderItems);
|
|
1341
|
+
|
|
1342
|
+
return [...plainSubfolders, ...sortableItems];
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
function ensureRoot(source, rootPath) {
|
|
1346
|
+
const rootLabel = rootPath || "project";
|
|
1347
|
+
ensureFolder(roots, rootPath || "__project__", rootLabel, source);
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function addFolderSegments(rootPath, segments, source) {
|
|
1351
|
+
ensureRoot(source, rootPath);
|
|
1352
|
+
|
|
1353
|
+
if (segments.length === 0) {
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
let current = ensureFolder(roots, rootPath || "__project__", rootPath || "project", source);
|
|
1358
|
+
let currentPath = rootPath;
|
|
1359
|
+
|
|
1360
|
+
for (const segment of segments) {
|
|
1361
|
+
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
|
|
1362
|
+
current = ensureFolder(current.childFolders, currentPath, segment, source);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
function addDirectoryPath(relativePath, sourceHint) {
|
|
1367
|
+
const { rootPath, segments, source } = resolveTreePath(relativePath, sourceHint);
|
|
1368
|
+
addFolderSegments(rootPath, segments, source);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
for (const target of scanTargets) {
|
|
1372
|
+
if (target.pathPrefix) {
|
|
1373
|
+
addDirectoryPath(target.pathPrefix, target.source);
|
|
1374
|
+
} else {
|
|
1375
|
+
ensureRoot(target.source, "");
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
for (const directoryPath of directories) {
|
|
1380
|
+
addDirectoryPath(directoryPath);
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
for (const file of files) {
|
|
1384
|
+
const { rootPath, segments, source } = resolveTreePath(file.relativePath, file.source);
|
|
1385
|
+
|
|
1386
|
+
if (segments.length === 0) {
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
ensureRoot(source, rootPath);
|
|
1391
|
+
|
|
1392
|
+
if (segments.length === 1) {
|
|
1393
|
+
const root = ensureFolder(roots, rootPath || "__project__", rootPath || "project", source);
|
|
1394
|
+
root.files.push({
|
|
1395
|
+
type: "file",
|
|
1396
|
+
name: segments[0],
|
|
1397
|
+
path: file.relativePath,
|
|
1398
|
+
file,
|
|
1399
|
+
});
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
let current = ensureFolder(roots, rootPath || "__project__", rootPath || "project", source);
|
|
1404
|
+
let currentPath = rootPath;
|
|
1405
|
+
|
|
1406
|
+
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
1407
|
+
currentPath = currentPath ? `${currentPath}/${segments[index]}` : segments[index];
|
|
1408
|
+
current = ensureFolder(
|
|
1409
|
+
current.childFolders,
|
|
1410
|
+
currentPath,
|
|
1411
|
+
segments[index],
|
|
1412
|
+
source,
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
const fileName = segments[segments.length - 1];
|
|
1417
|
+
current.files.push({
|
|
1418
|
+
type: "file",
|
|
1419
|
+
name: fileName,
|
|
1420
|
+
path: file.relativePath,
|
|
1421
|
+
file,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
const sourceOrder = { content: 0, pages: 1, project: 2 };
|
|
1426
|
+
|
|
1427
|
+
return [...roots.values()]
|
|
1428
|
+
.sort((a, b) => {
|
|
1429
|
+
const sourceDiff = sourceOrder[a.source] - sourceOrder[b.source];
|
|
1430
|
+
if (sourceDiff !== 0) {
|
|
1431
|
+
return sourceDiff;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
return a.name.localeCompare(b.name);
|
|
1435
|
+
})
|
|
1436
|
+
.map((root) => ({
|
|
1437
|
+
type: "folder",
|
|
1438
|
+
name: root.name,
|
|
1439
|
+
path: root.path === "__project__" ? "" : root.path,
|
|
1440
|
+
source: root.source,
|
|
1441
|
+
children: buildFolderChildren(root),
|
|
1442
|
+
}));
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
function updateFileResults() {
|
|
1446
|
+
const query = fileSearch.value.trim();
|
|
1447
|
+
const filteredFiles = filterFiles(scannedFiles, query);
|
|
1448
|
+
const isSearching = query.length > 0;
|
|
1449
|
+
|
|
1450
|
+
fileCount.textContent = formatFileCount(filteredFiles.length, scannedFiles.length);
|
|
1451
|
+
|
|
1452
|
+
if (filteredFiles.length === 0 && isSearching) {
|
|
1453
|
+
emptyState.hidden = false;
|
|
1454
|
+
emptyState.textContent = "No files match your search.";
|
|
1455
|
+
fileList.hidden = true;
|
|
1456
|
+
fileList.innerHTML = "";
|
|
1457
|
+
collapseAllBtn.hidden = true;
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
const tree = buildFileTree(filteredFiles, {
|
|
1462
|
+
directories: isSearching ? [] : scannedDirectories,
|
|
1463
|
+
scanTargets: lastScanTargets,
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
if (tree.length === 0) {
|
|
1467
|
+
emptyState.hidden = false;
|
|
1468
|
+
emptyState.textContent = "No .md or .mdx files found in this folder.";
|
|
1469
|
+
fileList.hidden = true;
|
|
1470
|
+
fileList.innerHTML = "";
|
|
1471
|
+
collapseAllBtn.hidden = true;
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (!isSearching) {
|
|
1476
|
+
ensureDefaultExpandedRoots(tree);
|
|
1477
|
+
} else {
|
|
1478
|
+
for (const folderPath of collectFolderPaths(tree)) {
|
|
1479
|
+
expandedPaths.add(folderPath);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if (filteredFiles.length === 0 && !isSearching) {
|
|
1484
|
+
emptyState.hidden = false;
|
|
1485
|
+
emptyState.textContent = "No .md or .mdx files found in this folder.";
|
|
1486
|
+
} else {
|
|
1487
|
+
emptyState.hidden = true;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
fileList.hidden = false;
|
|
1491
|
+
collapseAllBtn.hidden = false;
|
|
1492
|
+
renderFileTree(tree, { isSearching });
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
function applyScanData(data) {
|
|
1496
|
+
folderInput.value = data.projectPath;
|
|
1497
|
+
currentProjectPath = data.projectPath;
|
|
1498
|
+
setProjectInUrl(data.projectPath);
|
|
1499
|
+
loadExpandedPaths(currentProjectPath);
|
|
1500
|
+
|
|
1501
|
+
scanInfo.hidden = false;
|
|
1502
|
+
scanInfo.textContent = formatScanInfo(data.scanTargets);
|
|
1503
|
+
|
|
1504
|
+
scannedFiles = data.files;
|
|
1505
|
+
scannedDirectories = data.directories ?? [];
|
|
1506
|
+
lastScanTargets = data.scanTargets ?? [];
|
|
1507
|
+
fileSearch.value = "";
|
|
1508
|
+
searchBox.hidden =
|
|
1509
|
+
scannedFiles.length === 0 &&
|
|
1510
|
+
scannedDirectories.length === 0 &&
|
|
1511
|
+
lastScanTargets.length === 0;
|
|
1512
|
+
|
|
1513
|
+
updateFileResults();
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
async function refreshScan() {
|
|
1517
|
+
if (!currentProjectPath) {
|
|
1518
|
+
return null;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
const response = await fetch("/api/scan", {
|
|
1522
|
+
method: "POST",
|
|
1523
|
+
headers: { "Content-Type": "application/json" },
|
|
1524
|
+
body: JSON.stringify({ path: currentProjectPath }),
|
|
1525
|
+
});
|
|
1526
|
+
const data = await response.json();
|
|
1527
|
+
|
|
1528
|
+
if (!response.ok) {
|
|
1529
|
+
showError(data.error ?? "Could not refresh folder");
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
applyScanData(data);
|
|
1534
|
+
return data;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
function getProjectFromUrl() {
|
|
1538
|
+
return new URLSearchParams(window.location.search).get("project");
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function getFileFromUrl() {
|
|
1542
|
+
return new URLSearchParams(window.location.search).get("file");
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function setProjectInUrl(projectPath) {
|
|
1546
|
+
const url = new URL(window.location.href);
|
|
1547
|
+
url.searchParams.set("project", projectPath);
|
|
1548
|
+
window.history.replaceState({}, "", url);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
function setFileInUrl(filePath) {
|
|
1552
|
+
const url = new URL(window.location.href);
|
|
1553
|
+
if (filePath) {
|
|
1554
|
+
url.searchParams.set("file", filePath);
|
|
1555
|
+
} else {
|
|
1556
|
+
url.searchParams.delete("file");
|
|
1557
|
+
}
|
|
1558
|
+
window.history.replaceState({}, "", url);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function showListView() {
|
|
1562
|
+
listView.hidden = false;
|
|
1563
|
+
editView.hidden = true;
|
|
1564
|
+
currentEditFile = null;
|
|
1565
|
+
currentEditFrontmatter = null;
|
|
1566
|
+
frontmatterPanel.hidden = true;
|
|
1567
|
+
frontmatterEditor.setValue("");
|
|
1568
|
+
clearTimeout(saveButtonResetTimeout);
|
|
1569
|
+
setSaveButtonState({ disabled: true });
|
|
1570
|
+
setFileInUrl(null);
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
function getFrontmatterTitle(frontmatter) {
|
|
1574
|
+
if (!frontmatter) {
|
|
1575
|
+
return null;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const match = frontmatter.match(/^title:\s*(.+)$/m);
|
|
1579
|
+
if (!match) {
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
let value = match[1].trim();
|
|
1584
|
+
if (
|
|
1585
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
1586
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
1587
|
+
) {
|
|
1588
|
+
return value.slice(1, -1);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
return value;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function updateEditHeader(file, frontmatter = null) {
|
|
1595
|
+
editFileName.textContent = getFrontmatterTitle(frontmatter) ?? file.name;
|
|
1596
|
+
editFilePath.textContent = file.relativePath;
|
|
1597
|
+
editFilePath.title = file.absolutePath;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function splitFrontmatter(content) {
|
|
1601
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
1602
|
+
if (!match) {
|
|
1603
|
+
return { frontmatter: null, body: content };
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
return {
|
|
1607
|
+
frontmatter: match[1],
|
|
1608
|
+
body: content.slice(match[0].length),
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
function collapseBlankLines(text) {
|
|
1613
|
+
const lines = text.split(/\r?\n/);
|
|
1614
|
+
const result = [];
|
|
1615
|
+
let previousBlank = false;
|
|
1616
|
+
|
|
1617
|
+
for (const line of lines) {
|
|
1618
|
+
const isBlank = line.trim() === "";
|
|
1619
|
+
if (isBlank) {
|
|
1620
|
+
if (!previousBlank) {
|
|
1621
|
+
result.push("");
|
|
1622
|
+
}
|
|
1623
|
+
previousBlank = true;
|
|
1624
|
+
} else {
|
|
1625
|
+
result.push(line);
|
|
1626
|
+
previousBlank = false;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
return result.join("\n");
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function buildFileContent(frontmatter, body) {
|
|
1634
|
+
const trimmedFrontmatter = frontmatter?.trim() ?? "";
|
|
1635
|
+
let content =
|
|
1636
|
+
trimmedFrontmatter === ""
|
|
1637
|
+
? body
|
|
1638
|
+
: `---\n${trimmedFrontmatter}\n---\n${body}`;
|
|
1639
|
+
|
|
1640
|
+
content = collapseBlankLines(content);
|
|
1641
|
+
if (!content.endsWith("\n")) {
|
|
1642
|
+
content += "\n";
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return content;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function setSaveButtonState({ label = "Save", disabled = false, error = false } = {}) {
|
|
1649
|
+
editSaveBtn.textContent = label;
|
|
1650
|
+
editSaveBtn.disabled = disabled;
|
|
1651
|
+
editSaveBtn.classList.toggle("is-error", error);
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
function resetSaveButtonSoon(delay = 1800) {
|
|
1655
|
+
clearTimeout(saveButtonResetTimeout);
|
|
1656
|
+
saveButtonResetTimeout = setTimeout(() => {
|
|
1657
|
+
if (!isSavingFile) {
|
|
1658
|
+
setSaveButtonState({ disabled: !currentEditFile });
|
|
1659
|
+
}
|
|
1660
|
+
}, delay);
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
async function saveCurrentFile() {
|
|
1664
|
+
if (!currentEditFile || isSavingFile || markdownEditor.dataset.loading) {
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
flushEditorHistory();
|
|
1669
|
+
currentEditFrontmatter = normalizeFrontmatter(frontmatterEditor.getValue());
|
|
1670
|
+
const content = buildFileContent(currentEditFrontmatter, getEditorContent());
|
|
1671
|
+
|
|
1672
|
+
isSavingFile = true;
|
|
1673
|
+
setSaveButtonState({ label: "Saving…", disabled: true });
|
|
1674
|
+
|
|
1675
|
+
try {
|
|
1676
|
+
const response = await fetch("/api/file", {
|
|
1677
|
+
method: "POST",
|
|
1678
|
+
headers: { "Content-Type": "application/json" },
|
|
1679
|
+
body: JSON.stringify({
|
|
1680
|
+
path: currentEditFile.absolutePath,
|
|
1681
|
+
content,
|
|
1682
|
+
}),
|
|
1683
|
+
});
|
|
1684
|
+
const data = await response.json();
|
|
1685
|
+
|
|
1686
|
+
if (!response.ok) {
|
|
1687
|
+
setSaveButtonState({
|
|
1688
|
+
label: data.error ?? "Save failed",
|
|
1689
|
+
disabled: false,
|
|
1690
|
+
error: true,
|
|
1691
|
+
});
|
|
1692
|
+
resetSaveButtonSoon(2500);
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const { frontmatter, body } = splitFrontmatter(content);
|
|
1697
|
+
currentEditFrontmatter = normalizeFrontmatter(frontmatter ?? "");
|
|
1698
|
+
frontmatterEditor.setValue(currentEditFrontmatter ?? "");
|
|
1699
|
+
updateEditHeader(currentEditFile, currentEditFrontmatter);
|
|
1700
|
+
|
|
1701
|
+
const navOrder = parseNavOrderFromFrontmatter(currentEditFrontmatter);
|
|
1702
|
+
const fileIndex = scannedFiles.findIndex(
|
|
1703
|
+
(entry) => entry.absolutePath === currentEditFile.absolutePath,
|
|
1704
|
+
);
|
|
1705
|
+
if (fileIndex !== -1) {
|
|
1706
|
+
scannedFiles[fileIndex] = { ...scannedFiles[fileIndex], navOrder };
|
|
1707
|
+
updateFileResults();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
renderMarkdownEditor(body);
|
|
1711
|
+
resetEditorHistory(body);
|
|
1712
|
+
setSaveButtonState({ label: "Saved", disabled: false });
|
|
1713
|
+
resetSaveButtonSoon();
|
|
1714
|
+
} catch {
|
|
1715
|
+
setSaveButtonState({ label: "Save failed", disabled: false, error: true });
|
|
1716
|
+
resetSaveButtonSoon(2500);
|
|
1717
|
+
} finally {
|
|
1718
|
+
isSavingFile = false;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function updateFrontmatterPanel(frontmatter) {
|
|
1723
|
+
frontmatterEditor.setValue(frontmatter ?? "");
|
|
1724
|
+
frontmatterPanel.hidden = false;
|
|
1725
|
+
frontmatterPanel.open = false;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
function handleFrontmatterInput() {
|
|
1729
|
+
if (!currentEditFile) {
|
|
1730
|
+
return;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
currentEditFrontmatter = normalizeFrontmatter(frontmatterEditor.getValue());
|
|
1734
|
+
updateEditHeader(currentEditFile, currentEditFrontmatter);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function showEditView() {
|
|
1738
|
+
listView.hidden = true;
|
|
1739
|
+
editView.hidden = false;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
async function openEditView(file) {
|
|
1743
|
+
currentEditFile = file;
|
|
1744
|
+
setFileInUrl(file.absolutePath);
|
|
1745
|
+
showEditView();
|
|
1746
|
+
|
|
1747
|
+
updateEditHeader(file, null);
|
|
1748
|
+
renderMarkdownEditor("");
|
|
1749
|
+
resetEditorHistory("");
|
|
1750
|
+
updateFrontmatterPanel(null);
|
|
1751
|
+
setSaveButtonState({ disabled: true });
|
|
1752
|
+
markdownEditor.dataset.loading = "true";
|
|
1753
|
+
|
|
1754
|
+
try {
|
|
1755
|
+
const response = await fetch(`/api/file?path=${encodeURIComponent(file.absolutePath)}`);
|
|
1756
|
+
const data = await response.json();
|
|
1757
|
+
|
|
1758
|
+
if (!response.ok) {
|
|
1759
|
+
setMarkdownEditorMessage(data.error ?? "Could not load file");
|
|
1760
|
+
updateFrontmatterPanel(null);
|
|
1761
|
+
setSaveButtonState({ disabled: true });
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
if (currentEditFile?.absolutePath !== file.absolutePath) {
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
currentEditFrontmatter = data.frontmatter ?? null;
|
|
1770
|
+
updateEditHeader(file, currentEditFrontmatter);
|
|
1771
|
+
updateFrontmatterPanel(currentEditFrontmatter);
|
|
1772
|
+
renderMarkdownEditor(data.content);
|
|
1773
|
+
resetEditorHistory(data.content);
|
|
1774
|
+
setSaveButtonState({ disabled: false });
|
|
1775
|
+
} catch {
|
|
1776
|
+
if (currentEditFile?.absolutePath === file.absolutePath) {
|
|
1777
|
+
setMarkdownEditorMessage("Could not load file");
|
|
1778
|
+
updateFrontmatterPanel(null);
|
|
1779
|
+
setSaveButtonState({ disabled: true });
|
|
1780
|
+
}
|
|
1781
|
+
} finally {
|
|
1782
|
+
delete markdownEditor.dataset.loading;
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
markdownEditor.focus();
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function setBusy(isBusy) {
|
|
1789
|
+
browseBtn.disabled = isBusy;
|
|
1790
|
+
scanBtn.disabled = isBusy;
|
|
1791
|
+
for (const button of projectButtons) {
|
|
1792
|
+
button.disabled = isBusy;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function showError(message) {
|
|
1797
|
+
scanInfo.hidden = false;
|
|
1798
|
+
scanInfo.textContent = message;
|
|
1799
|
+
scanInfo.classList.add("error");
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
function clearError() {
|
|
1803
|
+
scanInfo.classList.remove("error");
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
function renderProjects(projects) {
|
|
1807
|
+
projectList.innerHTML = "";
|
|
1808
|
+
projectButtons = [];
|
|
1809
|
+
|
|
1810
|
+
if (projects.length === 0) {
|
|
1811
|
+
projectsSection.hidden = true;
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
projectsSection.hidden = false;
|
|
1816
|
+
|
|
1817
|
+
for (const project of projects) {
|
|
1818
|
+
const item = document.createElement("li");
|
|
1819
|
+
const button = document.createElement("button");
|
|
1820
|
+
button.type = "button";
|
|
1821
|
+
button.className = "project-item";
|
|
1822
|
+
|
|
1823
|
+
const name = document.createElement("span");
|
|
1824
|
+
name.className = "project-name";
|
|
1825
|
+
name.textContent = project.name;
|
|
1826
|
+
|
|
1827
|
+
const projectPath = document.createElement("span");
|
|
1828
|
+
projectPath.className = "project-path";
|
|
1829
|
+
projectPath.textContent = project.path;
|
|
1830
|
+
projectPath.title = project.path;
|
|
1831
|
+
|
|
1832
|
+
button.append(name, projectPath);
|
|
1833
|
+
button.addEventListener("click", () => {
|
|
1834
|
+
folderInput.value = project.path;
|
|
1835
|
+
scanFolder(project.path);
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
projectButtons.push(button);
|
|
1839
|
+
item.append(button);
|
|
1840
|
+
projectList.append(item);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
async function loadProjects() {
|
|
1845
|
+
try {
|
|
1846
|
+
const response = await fetch("/api/projects");
|
|
1847
|
+
const data = await response.json();
|
|
1848
|
+
|
|
1849
|
+
if (!response.ok) {
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
renderProjects(data.projects ?? []);
|
|
1854
|
+
} catch {
|
|
1855
|
+
// ignore missing or invalid saved projects
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
async function browseFolder() {
|
|
1860
|
+
setBusy(true);
|
|
1861
|
+
clearError();
|
|
1862
|
+
|
|
1863
|
+
try {
|
|
1864
|
+
const response = await fetch("/api/browse", { method: "POST" });
|
|
1865
|
+
const data = await response.json();
|
|
1866
|
+
|
|
1867
|
+
if (!response.ok) {
|
|
1868
|
+
if (response.status !== 400) {
|
|
1869
|
+
showError(data.error ?? "Could not open folder picker");
|
|
1870
|
+
}
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
folderInput.value = data.path;
|
|
1875
|
+
await scanFolder(data.path);
|
|
1876
|
+
} catch {
|
|
1877
|
+
showError("Could not open folder picker");
|
|
1878
|
+
} finally {
|
|
1879
|
+
setBusy(false);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
function formatScanInfo(scanTargets) {
|
|
1884
|
+
const astroTargets = scanTargets.filter((target) => target.source !== "project");
|
|
1885
|
+
|
|
1886
|
+
if (astroTargets.length === 0) {
|
|
1887
|
+
return "No src/content/ or src/pages/ found — scanning project root";
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
const labels = astroTargets.map((target) => `${target.pathPrefix}/`);
|
|
1891
|
+
return `Scanning ${labels.join(" and ")}`;
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
function createTreeContentsCountColumns(folderCount, fileCount) {
|
|
1895
|
+
const folderCol = document.createElement("span");
|
|
1896
|
+
folderCol.className = "tree-folder-count-col folders";
|
|
1897
|
+
if (folderCount === 0) {
|
|
1898
|
+
folderCol.classList.add("is-empty");
|
|
1899
|
+
}
|
|
1900
|
+
folderCol.innerHTML = `${icons.folder}<span class="tree-folder-count-value">${folderCount}</span>`;
|
|
1901
|
+
folderCol.setAttribute(
|
|
1902
|
+
"aria-label",
|
|
1903
|
+
folderCount === 1 ? "1 folder" : `${folderCount} folders`,
|
|
1904
|
+
);
|
|
1905
|
+
|
|
1906
|
+
const fileCol = document.createElement("span");
|
|
1907
|
+
fileCol.className = "tree-folder-count-col files";
|
|
1908
|
+
if (fileCount === 0) {
|
|
1909
|
+
fileCol.classList.add("is-empty");
|
|
1910
|
+
}
|
|
1911
|
+
fileCol.innerHTML = `${icons.fileText}<span class="tree-folder-count-value">${fileCount}</span>`;
|
|
1912
|
+
fileCol.setAttribute("aria-label", fileCount === 1 ? "1 file" : `${fileCount} files`);
|
|
1913
|
+
|
|
1914
|
+
return { folderCol, fileCol };
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function createFolderMain(...elements) {
|
|
1918
|
+
const main = document.createElement("div");
|
|
1919
|
+
main.className = "tree-folder-main";
|
|
1920
|
+
main.append(...elements);
|
|
1921
|
+
return main;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
function createFolderPrefix(...elements) {
|
|
1925
|
+
const prefix = document.createElement("div");
|
|
1926
|
+
prefix.className = "tree-folder-prefix";
|
|
1927
|
+
prefix.append(...elements);
|
|
1928
|
+
return prefix;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
function createFolderChevron() {
|
|
1932
|
+
const chevron = document.createElement("span");
|
|
1933
|
+
chevron.className = "tree-folder-chevron";
|
|
1934
|
+
chevron.setAttribute("aria-hidden", "true");
|
|
1935
|
+
return chevron;
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
function createFolderBadge() {
|
|
1939
|
+
const badge = document.createElement("span");
|
|
1940
|
+
badge.className = "badge folder";
|
|
1941
|
+
badge.innerHTML = `${icons.folder}<span>folder</span>`;
|
|
1942
|
+
return badge;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function createSourceBadge(source) {
|
|
1946
|
+
const sourceIcons = {
|
|
1947
|
+
content: icons.fileText,
|
|
1948
|
+
pages: icons.layout,
|
|
1949
|
+
project: icons.folder,
|
|
1950
|
+
};
|
|
1951
|
+
|
|
1952
|
+
const badge = document.createElement("span");
|
|
1953
|
+
badge.className = `badge source ${source}`;
|
|
1954
|
+
badge.innerHTML = `${sourceIcons[source] ?? ""}<span>${source}</span>`;
|
|
1955
|
+
return badge;
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
function appendMoveButtons(actions, file, { isSearching = false } = {}) {
|
|
1959
|
+
if (isSearching || file.source === "content") {
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
const { canMoveUp, canMoveDown } = getMoveState(file);
|
|
1964
|
+
|
|
1965
|
+
const upBtn = document.createElement("button");
|
|
1966
|
+
upBtn.type = "button";
|
|
1967
|
+
upBtn.className = "tree-action-btn move-btn";
|
|
1968
|
+
upBtn.innerHTML = icons.arrowUp;
|
|
1969
|
+
upBtn.title = `Move ${file.name} up`;
|
|
1970
|
+
upBtn.setAttribute("aria-label", `Move ${file.name} up`);
|
|
1971
|
+
upBtn.disabled = !canMoveUp;
|
|
1972
|
+
upBtn.addEventListener("click", (event) => {
|
|
1973
|
+
event.preventDefault();
|
|
1974
|
+
event.stopPropagation();
|
|
1975
|
+
moveFileInFolder(file, "up");
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
const downBtn = document.createElement("button");
|
|
1979
|
+
downBtn.type = "button";
|
|
1980
|
+
downBtn.className = "tree-action-btn move-btn";
|
|
1981
|
+
downBtn.innerHTML = icons.arrowDown;
|
|
1982
|
+
downBtn.title = `Move ${file.name} down`;
|
|
1983
|
+
downBtn.setAttribute("aria-label", `Move ${file.name} down`);
|
|
1984
|
+
downBtn.disabled = !canMoveDown;
|
|
1985
|
+
downBtn.addEventListener("click", (event) => {
|
|
1986
|
+
event.preventDefault();
|
|
1987
|
+
event.stopPropagation();
|
|
1988
|
+
moveFileInFolder(file, "down");
|
|
1989
|
+
});
|
|
1990
|
+
|
|
1991
|
+
actions.append(upBtn, downBtn);
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function createFileRow(node, depth, { isSearching = false } = {}) {
|
|
1995
|
+
const file = node.file;
|
|
1996
|
+
const item = document.createElement("li");
|
|
1997
|
+
item.className = "tree-file";
|
|
1998
|
+
item.style.setProperty("--depth", depth);
|
|
1999
|
+
|
|
2000
|
+
const extBadge = document.createElement("span");
|
|
2001
|
+
extBadge.className = `badge ${file.extension}`;
|
|
2002
|
+
extBadge.innerHTML = `${icons.fileText}<span>${file.extension}</span>`;
|
|
2003
|
+
|
|
2004
|
+
const name = document.createElement("span");
|
|
2005
|
+
name.className = "file-name";
|
|
2006
|
+
name.textContent = file.name;
|
|
2007
|
+
name.title = file.relativePath;
|
|
2008
|
+
|
|
2009
|
+
const main = document.createElement("div");
|
|
2010
|
+
main.className = "tree-file-main";
|
|
2011
|
+
main.append(extBadge, name);
|
|
2012
|
+
|
|
2013
|
+
const { folderCol, fileCol } = createTreeContentsCountColumns(0, 0);
|
|
2014
|
+
|
|
2015
|
+
const actions = document.createElement("div");
|
|
2016
|
+
actions.className = "file-actions";
|
|
2017
|
+
|
|
2018
|
+
appendMoveButtons(actions, file, { isSearching });
|
|
2019
|
+
|
|
2020
|
+
const editBtn = document.createElement("button");
|
|
2021
|
+
editBtn.type = "button";
|
|
2022
|
+
editBtn.className = "edit-btn";
|
|
2023
|
+
editBtn.innerHTML = icons.edit;
|
|
2024
|
+
editBtn.title = `Edit ${file.name}`;
|
|
2025
|
+
editBtn.setAttribute("aria-label", `Edit ${file.name}`);
|
|
2026
|
+
editBtn.addEventListener("click", () => openEditView(file));
|
|
2027
|
+
|
|
2028
|
+
const deleteBtn = document.createElement("button");
|
|
2029
|
+
deleteBtn.type = "button";
|
|
2030
|
+
deleteBtn.className = "tree-action-btn delete-btn";
|
|
2031
|
+
deleteBtn.innerHTML = icons.trash;
|
|
2032
|
+
deleteBtn.title = `Delete ${file.name}`;
|
|
2033
|
+
deleteBtn.setAttribute("aria-label", `Delete ${file.name}`);
|
|
2034
|
+
deleteBtn.addEventListener("click", (event) => {
|
|
2035
|
+
event.preventDefault();
|
|
2036
|
+
event.stopPropagation();
|
|
2037
|
+
openConfirmDeleteDialog({
|
|
2038
|
+
type: "file",
|
|
2039
|
+
name: file.name,
|
|
2040
|
+
relativePath: file.relativePath,
|
|
2041
|
+
});
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
actions.append(editBtn, deleteBtn);
|
|
2045
|
+
item.append(main, folderCol, fileCol, actions);
|
|
2046
|
+
|
|
2047
|
+
item.addEventListener("dblclick", (event) => {
|
|
2048
|
+
if (event.target.closest(".file-actions")) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
openEditView(file);
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
return item;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
function createPageFolderRow(node, depth, { isSearching = false } = {}) {
|
|
2058
|
+
const { pageFile } = node;
|
|
2059
|
+
const item = document.createElement("li");
|
|
2060
|
+
item.className = "tree-folder tree-page-folder";
|
|
2061
|
+
item.style.setProperty("--depth", depth);
|
|
2062
|
+
|
|
2063
|
+
const details = document.createElement("details");
|
|
2064
|
+
details.open = isSearching || expandedPaths.has(node.path);
|
|
2065
|
+
|
|
2066
|
+
details.addEventListener("toggle", () => {
|
|
2067
|
+
if (details.open) {
|
|
2068
|
+
expandedPaths.add(node.path);
|
|
2069
|
+
} else {
|
|
2070
|
+
expandedPaths.delete(node.path);
|
|
2071
|
+
}
|
|
2072
|
+
saveExpandedPaths();
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
const summary = document.createElement("summary");
|
|
2076
|
+
summary.className = "tree-folder-header";
|
|
2077
|
+
|
|
2078
|
+
const pageBadge = document.createElement("span");
|
|
2079
|
+
pageBadge.className = "badge page";
|
|
2080
|
+
pageBadge.innerHTML = `${icons.folder}<span>page folder</span>`;
|
|
2081
|
+
|
|
2082
|
+
const label = document.createElement("span");
|
|
2083
|
+
label.className = "tree-folder-name";
|
|
2084
|
+
label.textContent = node.name;
|
|
2085
|
+
label.title = pageFile.relativePath;
|
|
2086
|
+
|
|
2087
|
+
const { folders: folderCount, files: nestedFileCount } = countTreeContents(node.children);
|
|
2088
|
+
const { folderCol, fileCol } = createTreeContentsCountColumns(folderCount, nestedFileCount);
|
|
2089
|
+
|
|
2090
|
+
summary.append(
|
|
2091
|
+
createFolderMain(createFolderPrefix(pageBadge, createFolderChevron()), label),
|
|
2092
|
+
folderCol,
|
|
2093
|
+
fileCol,
|
|
2094
|
+
);
|
|
2095
|
+
|
|
2096
|
+
const actions = document.createElement("div");
|
|
2097
|
+
actions.className = "file-actions";
|
|
2098
|
+
|
|
2099
|
+
appendMoveButtons(actions, pageFile, { isSearching });
|
|
2100
|
+
|
|
2101
|
+
const editBtn = document.createElement("button");
|
|
2102
|
+
editBtn.type = "button";
|
|
2103
|
+
editBtn.className = "edit-btn";
|
|
2104
|
+
editBtn.innerHTML = icons.edit;
|
|
2105
|
+
editBtn.title = `Edit ${pageFile.name}`;
|
|
2106
|
+
editBtn.setAttribute("aria-label", `Edit ${pageFile.name}`);
|
|
2107
|
+
editBtn.addEventListener("click", (event) => {
|
|
2108
|
+
event.preventDefault();
|
|
2109
|
+
event.stopPropagation();
|
|
2110
|
+
openEditView(pageFile);
|
|
2111
|
+
});
|
|
2112
|
+
|
|
2113
|
+
const addBtn = document.createElement("button");
|
|
2114
|
+
addBtn.type = "button";
|
|
2115
|
+
addBtn.className = "tree-action-btn add-btn";
|
|
2116
|
+
addBtn.innerHTML = icons.plus;
|
|
2117
|
+
addBtn.title = `Add to ${node.name}`;
|
|
2118
|
+
addBtn.setAttribute("aria-label", `Add file or folder to ${node.name}`);
|
|
2119
|
+
addBtn.addEventListener("click", (event) => {
|
|
2120
|
+
event.preventDefault();
|
|
2121
|
+
event.stopPropagation();
|
|
2122
|
+
openNewItemDialog(node.path);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
const deleteBtn = document.createElement("button");
|
|
2126
|
+
deleteBtn.type = "button";
|
|
2127
|
+
deleteBtn.className = "tree-action-btn delete-btn";
|
|
2128
|
+
deleteBtn.innerHTML = icons.trash;
|
|
2129
|
+
deleteBtn.title = `Delete ${node.name}`;
|
|
2130
|
+
deleteBtn.setAttribute("aria-label", `Delete page folder ${node.name}`);
|
|
2131
|
+
deleteBtn.addEventListener("click", (event) => {
|
|
2132
|
+
event.preventDefault();
|
|
2133
|
+
event.stopPropagation();
|
|
2134
|
+
openConfirmDeleteDialog({
|
|
2135
|
+
type: "page-folder",
|
|
2136
|
+
name: node.name,
|
|
2137
|
+
relativePath: node.path,
|
|
2138
|
+
pageFilePath: pageFile.relativePath,
|
|
2139
|
+
pageFileName: pageFile.name,
|
|
2140
|
+
});
|
|
2141
|
+
});
|
|
2142
|
+
|
|
2143
|
+
actions.append(editBtn, addBtn, deleteBtn);
|
|
2144
|
+
summary.append(actions);
|
|
2145
|
+
|
|
2146
|
+
summary.addEventListener("dblclick", (event) => {
|
|
2147
|
+
if (event.target.closest(".file-actions")) {
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
event.preventDefault();
|
|
2151
|
+
event.stopPropagation();
|
|
2152
|
+
openEditView(pageFile);
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
details.append(summary);
|
|
2156
|
+
|
|
2157
|
+
if (node.children.length > 0) {
|
|
2158
|
+
const children = document.createElement("ul");
|
|
2159
|
+
children.className = "tree-children";
|
|
2160
|
+
|
|
2161
|
+
for (const child of node.children) {
|
|
2162
|
+
children.append(createTreeRow(child, depth + 1, { isSearching }));
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
details.append(children);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
item.append(details);
|
|
2169
|
+
return item;
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function createTreeRow(node, depth, { isSearching = false } = {}) {
|
|
2173
|
+
if (node.type === "folder") {
|
|
2174
|
+
return createFolderRow(node, depth, { isSearching });
|
|
2175
|
+
}
|
|
2176
|
+
|
|
2177
|
+
if (node.type === "page-folder") {
|
|
2178
|
+
return createPageFolderRow(node, depth, { isSearching });
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
return createFileRow(node, depth, { isSearching });
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
function createFolderRow(node, depth, { isSearching }) {
|
|
2185
|
+
const item = document.createElement("li");
|
|
2186
|
+
item.className = "tree-folder";
|
|
2187
|
+
item.style.setProperty("--depth", depth);
|
|
2188
|
+
|
|
2189
|
+
const details = document.createElement("details");
|
|
2190
|
+
details.open = isSearching || expandedPaths.has(node.path);
|
|
2191
|
+
|
|
2192
|
+
details.addEventListener("toggle", () => {
|
|
2193
|
+
if (details.open) {
|
|
2194
|
+
expandedPaths.add(node.path);
|
|
2195
|
+
} else {
|
|
2196
|
+
expandedPaths.delete(node.path);
|
|
2197
|
+
}
|
|
2198
|
+
saveExpandedPaths();
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
const summary = document.createElement("summary");
|
|
2202
|
+
summary.className = "tree-folder-header";
|
|
2203
|
+
|
|
2204
|
+
const label = document.createElement("span");
|
|
2205
|
+
label.className = "tree-folder-name";
|
|
2206
|
+
label.textContent = depth === 0 ? (node.path || node.name) : node.name;
|
|
2207
|
+
|
|
2208
|
+
const { folders: folderCount, files: nestedFileCount } = countTreeContents(node.children);
|
|
2209
|
+
const { folderCol, fileCol } = createTreeContentsCountColumns(folderCount, nestedFileCount);
|
|
2210
|
+
|
|
2211
|
+
if (depth > 0) {
|
|
2212
|
+
summary.append(
|
|
2213
|
+
createFolderMain(createFolderPrefix(createFolderBadge(), createFolderChevron()), label),
|
|
2214
|
+
folderCol,
|
|
2215
|
+
fileCol,
|
|
2216
|
+
);
|
|
2217
|
+
} else if (depth === 0 && node.source) {
|
|
2218
|
+
summary.append(
|
|
2219
|
+
createFolderMain(createFolderPrefix(createSourceBadge(node.source), createFolderChevron()), label),
|
|
2220
|
+
folderCol,
|
|
2221
|
+
fileCol,
|
|
2222
|
+
);
|
|
2223
|
+
} else {
|
|
2224
|
+
summary.append(
|
|
2225
|
+
createFolderMain(createFolderPrefix(createFolderChevron()), label),
|
|
2226
|
+
folderCol,
|
|
2227
|
+
fileCol,
|
|
2228
|
+
);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const actions = document.createElement("div");
|
|
2232
|
+
actions.className = "file-actions";
|
|
2233
|
+
|
|
2234
|
+
const addBtn = document.createElement("button");
|
|
2235
|
+
addBtn.type = "button";
|
|
2236
|
+
addBtn.className = "tree-action-btn add-btn";
|
|
2237
|
+
addBtn.innerHTML = icons.plus;
|
|
2238
|
+
addBtn.title = `Add to ${label.textContent}`;
|
|
2239
|
+
addBtn.setAttribute("aria-label", `Add file or folder to ${label.textContent}`);
|
|
2240
|
+
addBtn.addEventListener("click", (event) => {
|
|
2241
|
+
event.preventDefault();
|
|
2242
|
+
event.stopPropagation();
|
|
2243
|
+
openNewItemDialog(node.path);
|
|
2244
|
+
});
|
|
2245
|
+
|
|
2246
|
+
actions.append(addBtn);
|
|
2247
|
+
|
|
2248
|
+
if (depth > 0) {
|
|
2249
|
+
const deleteBtn = document.createElement("button");
|
|
2250
|
+
deleteBtn.type = "button";
|
|
2251
|
+
deleteBtn.className = "tree-action-btn delete-btn";
|
|
2252
|
+
deleteBtn.innerHTML = icons.trash;
|
|
2253
|
+
deleteBtn.title = `Delete ${node.name}`;
|
|
2254
|
+
deleteBtn.setAttribute("aria-label", `Delete folder ${node.name}`);
|
|
2255
|
+
deleteBtn.addEventListener("click", (event) => {
|
|
2256
|
+
event.preventDefault();
|
|
2257
|
+
event.stopPropagation();
|
|
2258
|
+
openConfirmDeleteDialog({
|
|
2259
|
+
type: "folder",
|
|
2260
|
+
name: node.name,
|
|
2261
|
+
relativePath: node.path,
|
|
2262
|
+
});
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
actions.append(deleteBtn);
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
summary.append(actions);
|
|
2269
|
+
|
|
2270
|
+
details.append(summary);
|
|
2271
|
+
|
|
2272
|
+
if (node.children.length > 0) {
|
|
2273
|
+
const children = document.createElement("ul");
|
|
2274
|
+
children.className = "tree-children";
|
|
2275
|
+
|
|
2276
|
+
for (const child of node.children) {
|
|
2277
|
+
children.append(createTreeRow(child, depth + 1, { isSearching }));
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
details.append(children);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2283
|
+
item.append(details);
|
|
2284
|
+
return item;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
function renderFileTree(tree, { isSearching = false } = {}) {
|
|
2288
|
+
fileList.innerHTML = "";
|
|
2289
|
+
fileList.className = "file-tree";
|
|
2290
|
+
|
|
2291
|
+
for (const node of tree) {
|
|
2292
|
+
fileList.append(createFolderRow(node, 0, { isSearching }));
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
async function scanFolder(pathValue = folderInput.value.trim()) {
|
|
2297
|
+
if (!pathValue) {
|
|
2298
|
+
showError("Enter or choose a folder path first");
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
setBusy(true);
|
|
2303
|
+
clearError();
|
|
2304
|
+
|
|
2305
|
+
try {
|
|
2306
|
+
const response = await fetch("/api/scan", {
|
|
2307
|
+
method: "POST",
|
|
2308
|
+
headers: { "Content-Type": "application/json" },
|
|
2309
|
+
body: JSON.stringify({ path: pathValue }),
|
|
2310
|
+
});
|
|
2311
|
+
|
|
2312
|
+
const data = await response.json();
|
|
2313
|
+
|
|
2314
|
+
if (!response.ok) {
|
|
2315
|
+
showError(data.error ?? "Could not scan folder");
|
|
2316
|
+
emptyState.hidden = false;
|
|
2317
|
+
emptyState.textContent =
|
|
2318
|
+
"Open Projects to choose a folder and scan for .md and .mdx files.";
|
|
2319
|
+
return;
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
applyScanData(data);
|
|
2323
|
+
await loadProjects();
|
|
2324
|
+
projectsDialog.close();
|
|
2325
|
+
} catch {
|
|
2326
|
+
showError("Could not scan folder");
|
|
2327
|
+
emptyState.hidden = false;
|
|
2328
|
+
emptyState.textContent =
|
|
2329
|
+
"Open Projects to choose a folder and scan for .md and .mdx files.";
|
|
2330
|
+
} finally {
|
|
2331
|
+
setBusy(false);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
async function createEntry(parentPath, name) {
|
|
2336
|
+
if (!currentProjectPath) {
|
|
2337
|
+
return { ok: false, error: "Choose a project folder first" };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
const trimmedName = name.trim();
|
|
2341
|
+
const existing = entryExistsAtPath(parentPath, trimmedName);
|
|
2342
|
+
if (existing.exists) {
|
|
2343
|
+
return {
|
|
2344
|
+
ok: false,
|
|
2345
|
+
error:
|
|
2346
|
+
existing.type === "file"
|
|
2347
|
+
? "A file with that name already exists"
|
|
2348
|
+
: "A folder with that name already exists",
|
|
2349
|
+
};
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
setBusy(true);
|
|
2353
|
+
clearError();
|
|
2354
|
+
|
|
2355
|
+
try {
|
|
2356
|
+
const response = await fetch("/api/entry", {
|
|
2357
|
+
method: "POST",
|
|
2358
|
+
headers: { "Content-Type": "application/json" },
|
|
2359
|
+
body: JSON.stringify({
|
|
2360
|
+
projectPath: currentProjectPath,
|
|
2361
|
+
parentPath,
|
|
2362
|
+
name: trimmedName,
|
|
2363
|
+
}),
|
|
2364
|
+
});
|
|
2365
|
+
const data = await response.json();
|
|
2366
|
+
|
|
2367
|
+
if (!response.ok) {
|
|
2368
|
+
return { ok: false, error: data.error ?? "Could not create entry" };
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
expandedPaths.add(parentPath);
|
|
2372
|
+
saveExpandedPaths();
|
|
2373
|
+
|
|
2374
|
+
await refreshScan();
|
|
2375
|
+
|
|
2376
|
+
if (data.type === "file") {
|
|
2377
|
+
const file = scannedFiles.find((entry) => entry.absolutePath === data.absolutePath) ?? {
|
|
2378
|
+
name: data.name,
|
|
2379
|
+
relativePath: data.relativePath,
|
|
2380
|
+
absolutePath: data.absolutePath ?? data.path,
|
|
2381
|
+
extension: data.extension,
|
|
2382
|
+
source: data.source,
|
|
2383
|
+
};
|
|
2384
|
+
await openEditView(file);
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
return { ok: true, data };
|
|
2388
|
+
} catch {
|
|
2389
|
+
return { ok: false, error: "Could not create entry" };
|
|
2390
|
+
} finally {
|
|
2391
|
+
setBusy(false);
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
function getTargetRelativePath(parentPath, name) {
|
|
2396
|
+
const normalizedParent = String(parentPath).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
2397
|
+
return normalizedParent ? `${normalizedParent}/${name}` : name;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function entryExistsAtPath(parentPath, name) {
|
|
2401
|
+
const targetPath = getTargetRelativePath(parentPath, name);
|
|
2402
|
+
|
|
2403
|
+
if (scannedFiles.some((file) => file.relativePath.replace(/\\/g, "/") === targetPath)) {
|
|
2404
|
+
return { exists: true, type: "file" };
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
if (scannedDirectories.includes(targetPath)) {
|
|
2408
|
+
return { exists: true, type: "folder" };
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
return { exists: false };
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function normalizeRelativePath(relativePath) {
|
|
2415
|
+
return String(relativePath).replace(/\\/g, "/").replace(/^\/+/, "");
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function isPathInsideDeletedEntry(filePath, deletedPath) {
|
|
2419
|
+
const normalizedFilePath = normalizeRelativePath(filePath);
|
|
2420
|
+
const normalizedDeletedPath = normalizeRelativePath(deletedPath);
|
|
2421
|
+
return (
|
|
2422
|
+
normalizedFilePath === normalizedDeletedPath ||
|
|
2423
|
+
normalizedFilePath.startsWith(`${normalizedDeletedPath}/`)
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
function removeExpandedPathsUnder(deletedPath) {
|
|
2428
|
+
const normalizedDeletedPath = normalizeRelativePath(deletedPath);
|
|
2429
|
+
|
|
2430
|
+
for (const expandedPath of [...expandedPaths]) {
|
|
2431
|
+
const normalizedExpandedPath = normalizeRelativePath(expandedPath);
|
|
2432
|
+
if (
|
|
2433
|
+
normalizedExpandedPath === normalizedDeletedPath ||
|
|
2434
|
+
normalizedExpandedPath.startsWith(`${normalizedDeletedPath}/`)
|
|
2435
|
+
) {
|
|
2436
|
+
expandedPaths.delete(expandedPath);
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
saveExpandedPaths();
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
async function deletePageFolder(entry) {
|
|
2444
|
+
if (!currentProjectPath) {
|
|
2445
|
+
return { ok: false, error: "Choose a project folder first" };
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
setBusy(true);
|
|
2449
|
+
clearError();
|
|
2450
|
+
|
|
2451
|
+
try {
|
|
2452
|
+
for (const relativePath of [entry.relativePath, entry.pageFilePath]) {
|
|
2453
|
+
const response = await fetch("/api/entry", {
|
|
2454
|
+
method: "DELETE",
|
|
2455
|
+
headers: { "Content-Type": "application/json" },
|
|
2456
|
+
body: JSON.stringify({
|
|
2457
|
+
projectPath: currentProjectPath,
|
|
2458
|
+
relativePath,
|
|
2459
|
+
}),
|
|
2460
|
+
});
|
|
2461
|
+
const data = await response.json();
|
|
2462
|
+
|
|
2463
|
+
if (!response.ok) {
|
|
2464
|
+
return { ok: false, error: data.error ?? "Could not delete entry" };
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
if (
|
|
2469
|
+
currentEditFile &&
|
|
2470
|
+
(isPathInsideDeletedEntry(currentEditFile.relativePath, entry.relativePath) ||
|
|
2471
|
+
isPathInsideDeletedEntry(currentEditFile.relativePath, entry.pageFilePath))
|
|
2472
|
+
) {
|
|
2473
|
+
showListView();
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
removeExpandedPathsUnder(entry.relativePath);
|
|
2477
|
+
await refreshScan();
|
|
2478
|
+
return { ok: true };
|
|
2479
|
+
} catch {
|
|
2480
|
+
return { ok: false, error: "Could not delete page folder" };
|
|
2481
|
+
} finally {
|
|
2482
|
+
setBusy(false);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
async function deleteEntry(relativePath) {
|
|
2487
|
+
if (!currentProjectPath) {
|
|
2488
|
+
return { ok: false, error: "Choose a project folder first" };
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
setBusy(true);
|
|
2492
|
+
clearError();
|
|
2493
|
+
|
|
2494
|
+
try {
|
|
2495
|
+
const response = await fetch("/api/entry", {
|
|
2496
|
+
method: "DELETE",
|
|
2497
|
+
headers: { "Content-Type": "application/json" },
|
|
2498
|
+
body: JSON.stringify({
|
|
2499
|
+
projectPath: currentProjectPath,
|
|
2500
|
+
relativePath,
|
|
2501
|
+
}),
|
|
2502
|
+
});
|
|
2503
|
+
const data = await response.json();
|
|
2504
|
+
|
|
2505
|
+
if (!response.ok) {
|
|
2506
|
+
return { ok: false, error: data.error ?? "Could not delete entry" };
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
if (
|
|
2510
|
+
currentEditFile &&
|
|
2511
|
+
isPathInsideDeletedEntry(currentEditFile.relativePath, relativePath)
|
|
2512
|
+
) {
|
|
2513
|
+
showListView();
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
removeExpandedPathsUnder(relativePath);
|
|
2517
|
+
await refreshScan();
|
|
2518
|
+
return { ok: true, data };
|
|
2519
|
+
} catch {
|
|
2520
|
+
return { ok: false, error: "Could not delete entry" };
|
|
2521
|
+
} finally {
|
|
2522
|
+
setBusy(false);
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
function createConfirmDeleteDialog() {
|
|
2527
|
+
const dialog = document.createElement("dialog");
|
|
2528
|
+
dialog.id = "confirm-delete-dialog";
|
|
2529
|
+
dialog.className = "confirm-dialog";
|
|
2530
|
+
dialog.innerHTML = `
|
|
2531
|
+
<div class="dialog-header">
|
|
2532
|
+
<h2 class="confirm-delete-title">Delete file?</h2>
|
|
2533
|
+
<button type="button" class="dialog-close confirm-delete-dialog-close" aria-label="Close">×</button>
|
|
2534
|
+
</div>
|
|
2535
|
+
<div class="confirm-dialog-body">
|
|
2536
|
+
<p class="confirm-delete-message"></p>
|
|
2537
|
+
<p class="confirm-delete-error" hidden></p>
|
|
2538
|
+
<div class="confirm-dialog-actions">
|
|
2539
|
+
<button type="button" class="confirm-cancel">Cancel</button>
|
|
2540
|
+
<button type="button" class="destructive confirm-delete-submit">Delete</button>
|
|
2541
|
+
</div>
|
|
2542
|
+
</div>
|
|
2543
|
+
`;
|
|
2544
|
+
|
|
2545
|
+
const titleEl = dialog.querySelector(".confirm-delete-title");
|
|
2546
|
+
const messageEl = dialog.querySelector(".confirm-delete-message");
|
|
2547
|
+
const errorEl = dialog.querySelector(".confirm-delete-error");
|
|
2548
|
+
const closeBtn = dialog.querySelector(".confirm-delete-dialog-close");
|
|
2549
|
+
const cancelBtn = dialog.querySelector(".confirm-cancel");
|
|
2550
|
+
const deleteBtn = dialog.querySelector(".confirm-delete-submit");
|
|
2551
|
+
let pendingEntry = null;
|
|
2552
|
+
|
|
2553
|
+
function clearDialogError() {
|
|
2554
|
+
errorEl.hidden = true;
|
|
2555
|
+
errorEl.textContent = "";
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
function showDialogError(message) {
|
|
2559
|
+
errorEl.textContent = message;
|
|
2560
|
+
errorEl.hidden = false;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
function setDialogBusy(isBusy) {
|
|
2564
|
+
deleteBtn.disabled = isBusy;
|
|
2565
|
+
cancelBtn.disabled = isBusy;
|
|
2566
|
+
closeBtn.disabled = isBusy;
|
|
2567
|
+
deleteBtn.textContent = isBusy ? "Deleting…" : "Delete";
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
closeBtn.addEventListener("click", () => {
|
|
2571
|
+
dialog.close();
|
|
2572
|
+
});
|
|
2573
|
+
|
|
2574
|
+
cancelBtn.addEventListener("click", () => {
|
|
2575
|
+
dialog.close();
|
|
2576
|
+
});
|
|
2577
|
+
|
|
2578
|
+
dialog.addEventListener("click", (event) => {
|
|
2579
|
+
if (event.target === dialog) {
|
|
2580
|
+
dialog.close();
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
|
|
2584
|
+
dialog.addEventListener("close", () => {
|
|
2585
|
+
pendingEntry = null;
|
|
2586
|
+
clearDialogError();
|
|
2587
|
+
setDialogBusy(false);
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
deleteBtn.addEventListener("click", async () => {
|
|
2591
|
+
if (!pendingEntry) {
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
|
|
2595
|
+
clearDialogError();
|
|
2596
|
+
setDialogBusy(true);
|
|
2597
|
+
|
|
2598
|
+
const result =
|
|
2599
|
+
pendingEntry.type === "page-folder"
|
|
2600
|
+
? await deletePageFolder(pendingEntry)
|
|
2601
|
+
: await deleteEntry(pendingEntry.relativePath);
|
|
2602
|
+
if (!result.ok) {
|
|
2603
|
+
setDialogBusy(false);
|
|
2604
|
+
showDialogError(result.error);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
dialog.close();
|
|
2609
|
+
});
|
|
2610
|
+
|
|
2611
|
+
function openConfirmDeleteDialog(entry) {
|
|
2612
|
+
pendingEntry = entry;
|
|
2613
|
+
clearDialogError();
|
|
2614
|
+
|
|
2615
|
+
if (entry.type === "folder") {
|
|
2616
|
+
titleEl.textContent = "Delete folder?";
|
|
2617
|
+
messageEl.textContent = `Delete "${entry.name}" and everything inside it? This cannot be undone.`;
|
|
2618
|
+
} else if (entry.type === "page-folder") {
|
|
2619
|
+
titleEl.textContent = "Delete page folder?";
|
|
2620
|
+
messageEl.textContent = `Delete "${entry.name}", its contents, and "${entry.pageFileName}"? This cannot be undone.`;
|
|
2621
|
+
} else {
|
|
2622
|
+
titleEl.textContent = "Delete file?";
|
|
2623
|
+
messageEl.textContent = `Delete "${entry.name}"? This cannot be undone.`;
|
|
2624
|
+
}
|
|
2625
|
+
|
|
2626
|
+
dialog.showModal();
|
|
2627
|
+
cancelBtn.focus();
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
document.body.append(dialog);
|
|
2631
|
+
|
|
2632
|
+
return { openConfirmDeleteDialog };
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
const { openConfirmDeleteDialog } = createConfirmDeleteDialog();
|
|
2636
|
+
|
|
2637
|
+
function createNewItemDialog() {
|
|
2638
|
+
const dialog = document.createElement("dialog");
|
|
2639
|
+
dialog.id = "new-item-dialog";
|
|
2640
|
+
dialog.className = "new-item-dialog";
|
|
2641
|
+
dialog.innerHTML = `
|
|
2642
|
+
<div class="dialog-header">
|
|
2643
|
+
<h2>New file or folder</h2>
|
|
2644
|
+
<button type="button" class="dialog-close new-item-dialog-close" aria-label="Close">×</button>
|
|
2645
|
+
</div>
|
|
2646
|
+
<form class="new-item-form">
|
|
2647
|
+
<div class="new-item-field">
|
|
2648
|
+
<label for="new-item-name">Name</label>
|
|
2649
|
+
<input
|
|
2650
|
+
id="new-item-name"
|
|
2651
|
+
type="text"
|
|
2652
|
+
autocomplete="off"
|
|
2653
|
+
spellcheck="false"
|
|
2654
|
+
placeholder="post.md or my-folder"
|
|
2655
|
+
/>
|
|
2656
|
+
<p class="new-item-hint">.md / .mdx creates a file; anything else creates a folder.</p>
|
|
2657
|
+
<p class="new-item-error" hidden></p>
|
|
2658
|
+
</div>
|
|
2659
|
+
<div class="new-item-form-actions">
|
|
2660
|
+
<button type="submit" class="primary">Create</button>
|
|
2661
|
+
</div>
|
|
2662
|
+
</form>
|
|
2663
|
+
`;
|
|
2664
|
+
|
|
2665
|
+
const closeBtn = dialog.querySelector(".new-item-dialog-close");
|
|
2666
|
+
const form = dialog.querySelector(".new-item-form");
|
|
2667
|
+
const nameInput = dialog.querySelector("#new-item-name");
|
|
2668
|
+
const errorEl = dialog.querySelector(".new-item-error");
|
|
2669
|
+
let pendingParentPath = "";
|
|
2670
|
+
|
|
2671
|
+
function clearDialogError() {
|
|
2672
|
+
errorEl.hidden = true;
|
|
2673
|
+
errorEl.textContent = "";
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function showDialogError(message) {
|
|
2677
|
+
errorEl.textContent = message;
|
|
2678
|
+
errorEl.hidden = false;
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
closeBtn.addEventListener("click", () => {
|
|
2682
|
+
dialog.close();
|
|
2683
|
+
});
|
|
2684
|
+
|
|
2685
|
+
dialog.addEventListener("click", (event) => {
|
|
2686
|
+
if (event.target === dialog) {
|
|
2687
|
+
dialog.close();
|
|
2688
|
+
}
|
|
2689
|
+
});
|
|
2690
|
+
|
|
2691
|
+
dialog.addEventListener("close", () => {
|
|
2692
|
+
pendingParentPath = "";
|
|
2693
|
+
form.reset();
|
|
2694
|
+
clearDialogError();
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
nameInput.addEventListener("input", clearDialogError);
|
|
2698
|
+
|
|
2699
|
+
form.addEventListener("submit", async (event) => {
|
|
2700
|
+
event.preventDefault();
|
|
2701
|
+
const name = nameInput.value.trim();
|
|
2702
|
+
if (!name) {
|
|
2703
|
+
return;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
clearDialogError();
|
|
2707
|
+
|
|
2708
|
+
const result = await createEntry(pendingParentPath, name);
|
|
2709
|
+
if (!result.ok) {
|
|
2710
|
+
showDialogError(result.error);
|
|
2711
|
+
nameInput.focus();
|
|
2712
|
+
nameInput.select();
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
dialog.close();
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
function openNewItemDialog(parentPath) {
|
|
2720
|
+
pendingParentPath = parentPath;
|
|
2721
|
+
clearDialogError();
|
|
2722
|
+
dialog.showModal();
|
|
2723
|
+
nameInput.focus();
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
document.body.append(dialog);
|
|
2727
|
+
|
|
2728
|
+
return { openNewItemDialog };
|
|
2729
|
+
}
|
|
2730
|
+
|
|
2731
|
+
const { openNewItemDialog } = createNewItemDialog();
|
|
2732
|
+
|
|
2733
|
+
browseBtn.addEventListener("click", browseFolder);
|
|
2734
|
+
scanBtn.addEventListener("click", () => scanFolder());
|
|
2735
|
+
collapseAllBtn.innerHTML = icons.collapseAll;
|
|
2736
|
+
collapseAllBtn.addEventListener("click", collapseAllFolders);
|
|
2737
|
+
fileSearch.addEventListener("input", updateFileResults);
|
|
2738
|
+
folderInput.addEventListener("keydown", (event) => {
|
|
2739
|
+
if (event.key === "Enter") {
|
|
2740
|
+
scanFolder();
|
|
2741
|
+
}
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
projectsMenuBtn.addEventListener("click", () => {
|
|
2745
|
+
projectsDialog.showModal();
|
|
2746
|
+
});
|
|
2747
|
+
|
|
2748
|
+
projectsDialogClose.addEventListener("click", () => {
|
|
2749
|
+
projectsDialog.close();
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
projectsDialog.addEventListener("click", (event) => {
|
|
2753
|
+
if (event.target === projectsDialog) {
|
|
2754
|
+
projectsDialog.close();
|
|
2755
|
+
}
|
|
2756
|
+
});
|
|
2757
|
+
|
|
2758
|
+
const imageLightbox = createImageLightbox();
|
|
2759
|
+
|
|
2760
|
+
function createImageLightbox() {
|
|
2761
|
+
const dialog = document.createElement("dialog");
|
|
2762
|
+
dialog.id = "image-lightbox";
|
|
2763
|
+
dialog.className = "image-lightbox";
|
|
2764
|
+
dialog.innerHTML = `
|
|
2765
|
+
<button type="button" class="dialog-close image-lightbox-close" aria-label="Close">×</button>
|
|
2766
|
+
<figure class="image-lightbox-figure">
|
|
2767
|
+
<img class="image-lightbox-img" alt="" />
|
|
2768
|
+
<figcaption class="image-lightbox-caption"></figcaption>
|
|
2769
|
+
</figure>
|
|
2770
|
+
`;
|
|
2771
|
+
|
|
2772
|
+
const closeBtn = dialog.querySelector(".image-lightbox-close");
|
|
2773
|
+
const image = dialog.querySelector(".image-lightbox-img");
|
|
2774
|
+
const caption = dialog.querySelector(".image-lightbox-caption");
|
|
2775
|
+
|
|
2776
|
+
closeBtn.addEventListener("click", () => {
|
|
2777
|
+
dialog.close();
|
|
2778
|
+
});
|
|
2779
|
+
|
|
2780
|
+
dialog.addEventListener("click", (event) => {
|
|
2781
|
+
if (event.target === dialog) {
|
|
2782
|
+
dialog.close();
|
|
2783
|
+
}
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
document.body.append(dialog);
|
|
2787
|
+
|
|
2788
|
+
return {
|
|
2789
|
+
open(src, alt = "") {
|
|
2790
|
+
image.src = resolveEditorImageUrl(src);
|
|
2791
|
+
image.alt = alt;
|
|
2792
|
+
caption.textContent = alt;
|
|
2793
|
+
caption.hidden = !alt;
|
|
2794
|
+
dialog.showModal();
|
|
2795
|
+
},
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
editBackBtn.insertAdjacentHTML("afterbegin", icons.chevronLeft);
|
|
2800
|
+
|
|
2801
|
+
editBackBtn.addEventListener("click", showListView);
|
|
2802
|
+
editSaveBtn.addEventListener("click", saveCurrentFile);
|
|
2803
|
+
markdownEditor.addEventListener("click", (event) => {
|
|
2804
|
+
const preview = event.target.closest(".editor-image-preview");
|
|
2805
|
+
if (!preview) {
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
event.preventDefault();
|
|
2810
|
+
imageLightbox.open(preview.dataset.src ?? "", preview.dataset.alt ?? "");
|
|
2811
|
+
});
|
|
2812
|
+
markdownEditor.addEventListener("mousedown", (event) => {
|
|
2813
|
+
if (event.target.closest(".editor-image-preview")) {
|
|
2814
|
+
event.preventDefault();
|
|
2815
|
+
}
|
|
2816
|
+
});
|
|
2817
|
+
markdownEditor.addEventListener("input", () => {
|
|
2818
|
+
reevaluateMarkdownEditorLines();
|
|
2819
|
+
scheduleEditorHistoryCommit();
|
|
2820
|
+
});
|
|
2821
|
+
|
|
2822
|
+
markdownEditor.addEventListener("beforeinput", (event) => {
|
|
2823
|
+
if (event.inputType === "historyUndo") {
|
|
2824
|
+
event.preventDefault();
|
|
2825
|
+
undoEditorChange();
|
|
2826
|
+
} else if (event.inputType === "historyRedo") {
|
|
2827
|
+
event.preventDefault();
|
|
2828
|
+
redoEditorChange();
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
|
|
2832
|
+
markdownEditor.addEventListener("keydown", (event) => {
|
|
2833
|
+
const isMod = event.metaKey || event.ctrlKey;
|
|
2834
|
+
if (!isMod) {
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
const key = event.key.toLowerCase();
|
|
2839
|
+
if (key === "s") {
|
|
2840
|
+
event.preventDefault();
|
|
2841
|
+
saveCurrentFile();
|
|
2842
|
+
} else if (key === "z" && !event.shiftKey) {
|
|
2843
|
+
event.preventDefault();
|
|
2844
|
+
undoEditorChange();
|
|
2845
|
+
} else if (key === "z" && event.shiftKey) {
|
|
2846
|
+
event.preventDefault();
|
|
2847
|
+
redoEditorChange();
|
|
2848
|
+
} else if (key === "y") {
|
|
2849
|
+
event.preventDefault();
|
|
2850
|
+
redoEditorChange();
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
const editorApi = {
|
|
2855
|
+
editor: markdownEditor,
|
|
2856
|
+
getProjectPath: () => currentProjectPath,
|
|
2857
|
+
undo: undoEditorChange,
|
|
2858
|
+
redo: redoEditorChange,
|
|
2859
|
+
runHistoryAction: runEditorHistoryAction,
|
|
2860
|
+
wrapSelection: wrapEditorSelection,
|
|
2861
|
+
flushHistory: flushEditorHistory,
|
|
2862
|
+
reevaluateLines: reevaluateMarkdownEditorLines,
|
|
2863
|
+
focus: () => markdownEditor.focus(),
|
|
2864
|
+
saveCaret: saveEditorCaret,
|
|
2865
|
+
setPendingCaret: (caret) => {
|
|
2866
|
+
pendingEditorCaret = caret;
|
|
2867
|
+
},
|
|
2868
|
+
clearPendingCaret: () => {
|
|
2869
|
+
pendingEditorCaret = null;
|
|
2870
|
+
},
|
|
2871
|
+
insertAtCaret: insertMarkdownAtCaret,
|
|
2872
|
+
onHistoryChange(listener) {
|
|
2873
|
+
historyChangeListeners.push(listener);
|
|
2874
|
+
listener({
|
|
2875
|
+
canUndo: editorHistoryIndex > 0,
|
|
2876
|
+
canRedo: editorHistoryIndex < editorHistory.length - 1,
|
|
2877
|
+
});
|
|
2878
|
+
},
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
async function initToolbar() {
|
|
2882
|
+
const response = await fetch("/api/tools");
|
|
2883
|
+
const { tools } = await response.json();
|
|
2884
|
+
|
|
2885
|
+
let currentGroup = null;
|
|
2886
|
+
let groupEl = null;
|
|
2887
|
+
|
|
2888
|
+
for (const toolId of tools) {
|
|
2889
|
+
const module = await import(`/tools/${toolId}.js`);
|
|
2890
|
+
const tool = module.default;
|
|
2891
|
+
|
|
2892
|
+
if (tool.group !== currentGroup) {
|
|
2893
|
+
if (currentGroup !== null) {
|
|
2894
|
+
const separator = document.createElement("div");
|
|
2895
|
+
separator.className = "toolbar-separator";
|
|
2896
|
+
separator.setAttribute("role", "separator");
|
|
2897
|
+
separator.setAttribute("aria-orientation", "vertical");
|
|
2898
|
+
editToolbar.append(separator);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
currentGroup = tool.group;
|
|
2902
|
+
groupEl = document.createElement("div");
|
|
2903
|
+
groupEl.className = "toolbar-group";
|
|
2904
|
+
editToolbar.append(groupEl);
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
tool.mount(groupEl, editorApi);
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
initToolbar();
|
|
2912
|
+
|
|
2913
|
+
syncTopBarHeight();
|
|
2914
|
+
window.addEventListener("resize", syncTopBarHeight);
|
|
2915
|
+
|
|
2916
|
+
async function resolveInitialProjectPath() {
|
|
2917
|
+
const projectFromUrl = getProjectFromUrl();
|
|
2918
|
+
if (projectFromUrl) {
|
|
2919
|
+
return projectFromUrl;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
try {
|
|
2923
|
+
const response = await fetch("/api/config");
|
|
2924
|
+
const data = await response.json();
|
|
2925
|
+
|
|
2926
|
+
if (response.ok && data.defaultProjectPath) {
|
|
2927
|
+
return data.defaultProjectPath;
|
|
2928
|
+
}
|
|
2929
|
+
} catch {
|
|
2930
|
+
// fall back to manual project selection
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
return null;
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
loadProjects().then(async () => {
|
|
2937
|
+
const projectPath = await resolveInitialProjectPath();
|
|
2938
|
+
const fileFromUrl = getFileFromUrl();
|
|
2939
|
+
|
|
2940
|
+
if (projectPath) {
|
|
2941
|
+
await scanFolder(projectPath);
|
|
2942
|
+
|
|
2943
|
+
if (fileFromUrl) {
|
|
2944
|
+
const file = scannedFiles.find((entry) => entry.absolutePath === fileFromUrl);
|
|
2945
|
+
if (file) {
|
|
2946
|
+
await openEditView(file);
|
|
2947
|
+
} else {
|
|
2948
|
+
setFileInUrl(null);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
});
|