starmark 1.0.2 → 1.0.3

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.
@@ -1,32 +1,11 @@
1
1
  import { attachToolbarButton, createToolbarButton } from "../toolkit.js";
2
2
  import { icons } from "../icons.js";
3
-
4
- const IMAGE_UPLOAD_ACCEPT = ".png,.jpg,.jpeg,.gif,.webp,.svg,.avif,.ico,image/*";
5
- const IMAGE_UPLOAD_EXTENSIONS = new Set([
6
- ".png",
7
- ".jpg",
8
- ".jpeg",
9
- ".gif",
10
- ".webp",
11
- ".svg",
12
- ".avif",
13
- ".ico",
14
- ]);
15
-
16
- let pendingImage = null;
3
+ import { createMediaDialog } from "../media-browser.js";
17
4
 
18
5
  function escapeMarkdownAttribute(value) {
19
6
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
20
7
  }
21
8
 
22
- function filenameToAlt(filename) {
23
- const base = filename.replace(/\.[^.]+$/, "");
24
- return base
25
- .replace(/[-_]+/g, " ")
26
- .replace(/\s+/g, " ")
27
- .trim();
28
- }
29
-
30
9
  function buildFigureMarkdown(webPath, { alt, lazyLoad, caption }) {
31
10
  const safeAlt = escapeMarkdownAttribute(alt);
32
11
  const attributes = [`src="${webPath}"`, `alt="${safeAlt}"`];
@@ -46,434 +25,34 @@ function buildFigureMarkdown(webPath, { alt, lazyLoad, caption }) {
46
25
  return lines.join("\n");
47
26
  }
48
27
 
49
- function createMediaDialog(api) {
50
- const dialog = document.createElement("dialog");
51
- dialog.id = "media-dialog";
52
- dialog.className = "media-dialog";
53
- dialog.innerHTML = `
54
- <div class="dialog-header">
55
- <h2>Insert image</h2>
56
- <button type="button" class="dialog-close media-dialog-close" aria-label="Close">&times;</button>
57
- </div>
58
- <div class="media-browser">
59
- <div class="media-toolbar">
60
- <button type="button" class="media-up-btn" disabled aria-label="Go up one folder">
61
- ${icons.arrowUp}
62
- Up
63
- </button>
64
- <nav class="media-breadcrumb" aria-label="Media path"></nav>
65
- </div>
66
- <div class="media-search">
67
- <input
68
- type="search"
69
- class="media-search-input"
70
- placeholder="Search by file name"
71
- autocomplete="off"
72
- spellcheck="false"
73
- aria-label="Search images by file name"
74
- />
75
- </div>
76
- <p class="media-status" hidden></p>
77
- <div class="media-grid"></div>
78
- </div>
79
- <form class="image-form" hidden aria-hidden="true">
80
- <p class="image-selected-name"></p>
81
- <div class="link-field">
82
- <label for="image-alt">Alt text</label>
83
- <input id="image-alt" type="text" autocomplete="off" spellcheck="true" />
84
- </div>
85
- <label class="image-checkbox-field" for="image-lazy">
86
- <input id="image-lazy" type="checkbox" checked />
87
- Lazy load
88
- </label>
89
- <div class="link-field">
90
- <label for="image-caption">Caption</label>
91
- <input id="image-caption" type="text" autocomplete="off" spellcheck="true" />
92
- </div>
93
- <div class="link-form-actions image-form-actions">
94
- <button type="button" class="image-back-btn">Back</button>
95
- <button type="submit" class="primary">Done</button>
96
- </div>
97
- </form>
98
- `;
99
-
100
- const closeBtn = dialog.querySelector(".media-dialog-close");
101
- const mediaUpBtn = dialog.querySelector(".media-up-btn");
102
- const mediaBreadcrumb = dialog.querySelector(".media-breadcrumb");
103
- const mediaSearchInput = dialog.querySelector(".media-search-input");
104
- const mediaStatus = dialog.querySelector(".media-status");
105
- const mediaGrid = dialog.querySelector(".media-grid");
106
- const mediaBrowserView = dialog.querySelector(".media-browser");
107
- const imageForm = dialog.querySelector(".image-form");
108
- const imageSelectedName = dialog.querySelector(".image-selected-name");
109
- const imageAltInput = dialog.querySelector("#image-alt");
110
- const imageLazyInput = dialog.querySelector("#image-lazy");
111
- const imageCaptionInput = dialog.querySelector("#image-caption");
112
- const imageBackBtn = dialog.querySelector(".image-back-btn");
113
-
114
- let currentMediaDir = "img";
115
- let isMediaUploading = false;
116
-
117
- function formatMediaDirLabel(relativeDir) {
118
- return relativeDir ? relativeDir.replace(/\//g, " / ") : "public";
119
- }
120
-
121
- function getMediaFileUrl(relativePath) {
122
- return `/api/media/file?project=${encodeURIComponent(api.getProjectPath())}&path=${encodeURIComponent(relativePath)}`;
123
- }
124
-
125
- function getImageSearchLabel(image, searchRootDir) {
126
- if (image.dir === searchRootDir) {
127
- return image.name;
128
- }
129
-
130
- const fullPath = image.dir ? `${image.dir}/${image.name}` : image.name;
131
- const prefix = searchRootDir ? `${searchRootDir}/` : "";
132
-
133
- if (searchRootDir && fullPath.startsWith(prefix)) {
134
- return fullPath.slice(prefix.length);
135
- }
136
-
137
- return fullPath;
138
- }
139
-
140
- function navigateToMediaDirectory(relativeDir) {
141
- mediaSearchInput.value = "";
142
- loadMediaDirectory(relativeDir);
143
- }
144
-
145
- function isImageUploadFile(file) {
146
- const dotIndex = file.name.lastIndexOf(".");
147
- if (dotIndex === -1) {
148
- return false;
149
- }
150
-
151
- const extension = file.name.slice(dotIndex).toLowerCase();
152
- return IMAGE_UPLOAD_EXTENSIONS.has(extension);
153
- }
154
-
155
- function createMediaAddItem() {
156
- const label = document.createElement("label");
157
- label.className = "media-item-btn media-add-btn";
158
-
159
- const input = document.createElement("input");
160
- input.type = "file";
161
- input.className = "media-upload-input";
162
- input.accept = IMAGE_UPLOAD_ACCEPT;
163
- input.disabled = isMediaUploading;
164
-
165
- const preview = document.createElement("span");
166
- preview.className = "media-item-preview media-add-preview";
167
- preview.innerHTML = icons.plus;
168
- preview.setAttribute("aria-hidden", "true");
169
-
170
- const name = document.createElement("span");
171
- name.className = "media-item-name";
172
- name.textContent = "Add";
173
-
174
- label.append(input, preview, name);
175
- label.title = "Upload image";
176
-
177
- input.addEventListener("change", async () => {
178
- const file = input.files?.[0];
179
- input.value = "";
180
-
181
- if (!file) {
182
- return;
183
- }
184
-
185
- if (!isImageUploadFile(file)) {
186
- setMediaStatus("Only image files are supported.", { isError: true });
187
- return;
188
- }
189
-
190
- isMediaUploading = true;
191
- input.disabled = true;
192
- setMediaStatus("Uploading…");
193
-
194
- try {
195
- const params = new URLSearchParams({
196
- project: api.getProjectPath(),
197
- dir: currentMediaDir,
198
- filename: file.name,
199
- });
200
- const response = await fetch(`/api/media/upload?${params.toString()}`, {
201
- method: "POST",
202
- headers: {
203
- "Content-Type": file.type || "application/octet-stream",
204
- },
205
- body: file,
206
- });
207
- const data = await response.json();
208
-
209
- if (!response.ok) {
210
- setMediaStatus(data.error ?? "Could not upload image", { isError: true });
211
- return;
212
- }
213
-
214
- await loadMediaDirectory(currentMediaDir);
215
- } catch {
216
- setMediaStatus("Could not upload image", { isError: true });
217
- } finally {
218
- isMediaUploading = false;
219
- input.disabled = false;
220
- }
221
- });
222
-
223
- return label;
224
- }
225
-
226
- function setMediaStatus(message, { isError = false } = {}) {
227
- mediaStatus.hidden = !message;
228
- mediaStatus.textContent = message ?? "";
229
- mediaStatus.classList.toggle("is-error", Boolean(isError && message));
230
- }
231
-
232
- function renderMediaBreadcrumb(currentDir) {
233
- mediaBreadcrumb.replaceChildren();
234
-
235
- const segments = currentDir ? currentDir.split("/") : [];
236
- const crumbs = [{ label: "public", dir: "" }];
237
-
238
- for (let index = 0; index < segments.length; index += 1) {
239
- crumbs.push({
240
- label: segments[index],
241
- dir: segments.slice(0, index + 1).join("/"),
242
- });
243
- }
244
-
245
- crumbs.forEach((crumb, index) => {
246
- if (index > 0) {
247
- const separator = document.createElement("span");
248
- separator.className = "media-breadcrumb-separator";
249
- separator.textContent = "/";
250
- separator.setAttribute("aria-hidden", "true");
251
- mediaBreadcrumb.append(separator);
252
- }
253
-
254
- const button = document.createElement("button");
255
- button.type = "button";
256
- button.textContent = crumb.label;
257
- button.addEventListener("click", () => {
258
- navigateToMediaDirectory(crumb.dir);
259
- });
260
- mediaBreadcrumb.append(button);
261
- });
262
- }
263
-
264
- function showMediaBrowserView() {
265
- pendingImage = null;
266
- mediaBrowserView.hidden = false;
267
- mediaBrowserView.setAttribute("aria-hidden", "false");
268
- imageForm.hidden = true;
269
- imageForm.setAttribute("aria-hidden", "true");
270
- }
271
-
272
- function showMediaDetailsView(image) {
273
- pendingImage = image;
274
- imageSelectedName.textContent = image.name;
275
- imageAltInput.value = filenameToAlt(image.name);
276
- imageLazyInput.checked = true;
277
- imageCaptionInput.value = "";
278
- mediaBrowserView.hidden = true;
279
- mediaBrowserView.setAttribute("aria-hidden", "true");
280
- imageForm.hidden = false;
281
- imageForm.setAttribute("aria-hidden", "false");
282
- imageAltInput.focus();
283
- }
284
-
285
- function renderMediaDirectory(data) {
286
- const isSearching = Boolean(data.searchQuery);
287
-
288
- mediaUpBtn.disabled = data.parentDir === null;
289
- mediaUpBtn.onclick = () => {
290
- if (data.parentDir !== null) {
291
- navigateToMediaDirectory(data.parentDir === "" ? "" : data.parentDir);
292
- }
293
- };
294
-
295
- renderMediaBreadcrumb(data.currentDir);
296
- mediaGrid.replaceChildren();
297
-
298
- if (!isSearching) {
299
- for (const folder of data.folders) {
300
- const button = document.createElement("button");
301
- button.type = "button";
302
- button.className = "media-item-btn media-folder-btn";
303
- button.title = folder.name;
304
-
305
- const preview = document.createElement("span");
306
- preview.className = "media-item-preview media-folder-preview";
307
- preview.innerHTML = icons.folder;
308
- preview.setAttribute("aria-hidden", "true");
309
-
310
- const label = document.createElement("span");
311
- label.className = "media-item-name";
312
- label.textContent = folder.name;
313
-
314
- button.append(preview, label);
315
- button.addEventListener("click", () => {
316
- navigateToMediaDirectory(folder.dir);
317
- });
318
- mediaGrid.append(button);
319
- }
320
-
321
- mediaGrid.append(createMediaAddItem());
322
- }
323
-
324
- for (const image of data.images) {
325
- const relativePath = image.webPath.replace(/^\//, "");
326
- const displayName = isSearching
327
- ? getImageSearchLabel(image, data.currentDir)
328
- : image.name;
329
- const button = document.createElement("button");
330
- button.type = "button";
331
- button.className = "media-item-btn media-image-btn";
332
- button.title = displayName;
333
-
334
- const preview = document.createElement("img");
335
- preview.className = "media-item-preview media-image-preview";
336
- preview.src = getMediaFileUrl(relativePath);
337
- preview.alt = "";
338
- preview.loading = "lazy";
339
-
340
- const label = document.createElement("span");
341
- label.className = "media-item-name";
342
- label.textContent = displayName;
343
-
344
- button.append(preview, label);
345
- button.addEventListener("click", () => {
346
- showMediaDetailsView(image);
347
- });
348
-
349
- mediaGrid.append(button);
350
- }
351
-
352
- if (data.folders.length === 0 && data.images.length === 0) {
353
- if (isSearching) {
354
- setMediaStatus(
355
- `No images matching "${data.searchQuery}" in ${formatMediaDirLabel(data.currentDir)}.`,
356
- );
357
- } else {
358
- setMediaStatus("");
359
- }
360
- } else {
361
- setMediaStatus("");
362
- }
363
- }
364
-
365
- async function loadMediaDirectory(relativeDir = currentMediaDir) {
366
- currentMediaDir = relativeDir;
367
- const searchQuery = mediaSearchInput.value.trim();
368
-
369
- if (!api.getProjectPath()) {
370
- setMediaStatus("Open and scan a project first.", { isError: true });
371
- mediaGrid.replaceChildren();
372
- mediaUpBtn.disabled = true;
373
- mediaBreadcrumb.replaceChildren();
374
- return;
375
- }
376
-
377
- setMediaStatus(searchQuery ? "Searching…" : "Loading…");
378
-
379
- try {
380
- const params = new URLSearchParams({
381
- project: api.getProjectPath(),
382
- dir: relativeDir,
383
- });
384
-
385
- if (searchQuery) {
386
- params.set("q", searchQuery);
387
- }
388
-
389
- const response = await fetch(`/api/media?${params.toString()}`);
390
- const data = await response.json();
391
-
392
- if (!response.ok) {
393
- setMediaStatus(data.error ?? "Could not load media", { isError: true });
394
- mediaGrid.replaceChildren();
395
- mediaUpBtn.disabled = true;
396
- mediaBreadcrumb.replaceChildren();
397
- return;
398
- }
399
-
400
- renderMediaDirectory(data);
401
- } catch {
402
- setMediaStatus("Could not load media", { isError: true });
403
- mediaGrid.replaceChildren();
404
- mediaUpBtn.disabled = true;
405
- mediaBreadcrumb.replaceChildren();
406
- }
407
- }
408
-
409
- function openMediaDialog() {
410
- const caret = api.saveCaret();
411
- api.setPendingCaret(caret);
412
- showMediaBrowserView();
413
- mediaSearchInput.value = "";
414
- currentMediaDir = "img";
415
- dialog.showModal();
416
- loadMediaDirectory("img");
417
- }
418
-
419
- closeBtn.addEventListener("click", () => {
420
- dialog.close();
421
- });
422
-
423
- dialog.addEventListener("click", (event) => {
424
- if (event.target === dialog) {
425
- dialog.close();
426
- }
427
- });
428
-
429
- dialog.addEventListener("close", () => {
430
- api.clearPendingCaret();
431
- showMediaBrowserView();
432
- });
433
-
434
- imageForm.addEventListener("submit", (event) => {
435
- event.preventDefault();
436
-
437
- if (!pendingImage) {
438
- return;
439
- }
440
-
441
- api.flushHistory();
442
-
443
- const markdown = buildFigureMarkdown(pendingImage.webPath, {
444
- alt: imageAltInput.value,
445
- lazyLoad: imageLazyInput.checked,
446
- caption: imageCaptionInput.value,
447
- });
448
-
449
- if (api.insertAtCaret(markdown)) {
450
- dialog.close();
451
- }
452
- });
453
-
454
- imageBackBtn.addEventListener("click", () => {
455
- showMediaBrowserView();
456
- });
457
-
458
- mediaSearchInput.addEventListener("input", () => {
459
- loadMediaDirectory(currentMediaDir);
460
- });
461
-
462
- imageAltInput.addEventListener("keydown", (event) => {
463
- if (event.key === "Enter") {
464
- event.preventDefault();
465
- imageCaptionInput.focus();
466
- }
467
- });
468
-
469
- return { dialog, openMediaDialog };
470
- }
471
-
472
28
  export default {
473
29
  group: "insert",
474
30
 
475
31
  mount(container, api) {
476
- const { dialog, openMediaDialog } = createMediaDialog(api);
32
+ const { dialog, openMediaDialog } = createMediaDialog({
33
+ getProjectPath: () => api.getProjectPath(),
34
+ title: "Insert image",
35
+ selectMode: "details",
36
+ dialogId: "media-dialog-insert",
37
+ onOpen() {
38
+ const caret = api.saveCaret();
39
+ api.setPendingCaret(caret);
40
+ },
41
+ onClose() {
42
+ api.clearPendingCaret();
43
+ },
44
+ onInsert(image, { alt, lazyLoad, caption }) {
45
+ api.flushHistory();
46
+
47
+ const markdown = buildFigureMarkdown(image.webPath, {
48
+ alt,
49
+ lazyLoad,
50
+ caption,
51
+ });
52
+
53
+ return api.insertAtCaret(markdown);
54
+ },
55
+ });
477
56
  document.body.append(dialog);
478
57
 
479
58
  const button = createToolbarButton({