@mdzip/editor 1.2.6 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +12 -0
- package/README.md +42 -4
- package/dist/rendering.d.ts.map +1 -1
- package/dist/rendering.js +21 -2
- package/dist/rendering.js.map +1 -1
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +154 -4
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +105 -6
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +873 -67
- package/dist/view.js.map +1 -1
- package/dist/workspace.d.ts +63 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +369 -9
- package/dist/workspace.js.map +1 -1
- package/package.json +16 -4
package/dist/view.js
CHANGED
|
@@ -2,12 +2,12 @@ import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirro
|
|
|
2
2
|
import { markdown } from '@codemirror/lang-markdown';
|
|
3
3
|
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
4
4
|
import { Compartment, EditorState } from '@codemirror/state';
|
|
5
|
-
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
|
5
|
+
import { EditorView, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
6
6
|
import { tags } from '@lezer/highlight';
|
|
7
7
|
import { Bold, ChevronDown, Code, Columns2, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, Moon, PanelLeft, Quote, Save, SquarePen, Strikethrough, Sun, ZoomIn } from 'lucide';
|
|
8
8
|
import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
|
|
9
9
|
import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
|
|
10
|
-
import { MdzipWorkspaceService, extensionForMime } from './workspace.js';
|
|
10
|
+
import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
|
|
11
11
|
import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
|
|
12
12
|
import { WORKSPACE_CSS } from './view-css.js';
|
|
13
13
|
const STYLE_ATTR = 'data-mdzip-ws-styles';
|
|
@@ -88,7 +88,8 @@ const CONTROL_PRESETS = {
|
|
|
88
88
|
save: false,
|
|
89
89
|
zoom: false,
|
|
90
90
|
colorScheme: false,
|
|
91
|
-
orphanActions: false
|
|
91
|
+
orphanActions: false,
|
|
92
|
+
fileActions: false
|
|
92
93
|
},
|
|
93
94
|
viewer: {
|
|
94
95
|
preset: 'viewer',
|
|
@@ -101,7 +102,8 @@ const CONTROL_PRESETS = {
|
|
|
101
102
|
save: false,
|
|
102
103
|
zoom: true,
|
|
103
104
|
colorScheme: true,
|
|
104
|
-
orphanActions: false
|
|
105
|
+
orphanActions: false,
|
|
106
|
+
fileActions: false
|
|
105
107
|
},
|
|
106
108
|
'standalone-editor': {
|
|
107
109
|
preset: 'standalone-editor',
|
|
@@ -114,7 +116,8 @@ const CONTROL_PRESETS = {
|
|
|
114
116
|
save: true,
|
|
115
117
|
zoom: true,
|
|
116
118
|
colorScheme: true,
|
|
117
|
-
orphanActions: true
|
|
119
|
+
orphanActions: true,
|
|
120
|
+
fileActions: true
|
|
118
121
|
},
|
|
119
122
|
'hosted-editor': {
|
|
120
123
|
preset: 'hosted-editor',
|
|
@@ -127,7 +130,8 @@ const CONTROL_PRESETS = {
|
|
|
127
130
|
save: false,
|
|
128
131
|
zoom: true,
|
|
129
132
|
colorScheme: true,
|
|
130
|
-
orphanActions: true
|
|
133
|
+
orphanActions: true,
|
|
134
|
+
fileActions: true
|
|
131
135
|
}
|
|
132
136
|
};
|
|
133
137
|
export function resolveMdzipControlPolicy(controls) {
|
|
@@ -302,42 +306,52 @@ function attributesToHtml(attrs) {
|
|
|
302
306
|
.map(([key, value]) => ` ${key}="${escapeHtml(String(value))}"`)
|
|
303
307
|
.join('');
|
|
304
308
|
}
|
|
305
|
-
function renderNavNode(node, state,
|
|
309
|
+
function renderNavNode(node, state, options) {
|
|
306
310
|
if (node.entry) {
|
|
307
311
|
const isCurrent = node.entry.path === state.currentPath;
|
|
308
312
|
const isOrphaned = isOrphanedMdzipAsset(node.entry, state);
|
|
313
|
+
const isEntryPoint = node.entry.path === state.content.entryPoint;
|
|
314
|
+
const isManifest = isMdzipManifestPath(node.entry.path);
|
|
309
315
|
const iconKind = mdzipEntryIconKind(node.entry);
|
|
310
316
|
const safePath = escapeHtml(node.entry.path);
|
|
311
317
|
const safeName = escapeHtml(node.name);
|
|
312
318
|
const title = isOrphaned
|
|
313
319
|
? `${safePath} - not referenced by the entry markdown`
|
|
314
|
-
:
|
|
315
|
-
|
|
316
|
-
|
|
320
|
+
: isEntryPoint
|
|
321
|
+
? `${safePath} — entry point`
|
|
322
|
+
: safePath;
|
|
323
|
+
const classes = [
|
|
324
|
+
'nav-file',
|
|
325
|
+
isCurrent ? 'current-entry' : '',
|
|
326
|
+
isOrphaned ? 'orphaned-asset' : '',
|
|
327
|
+
isEntryPoint ? 'entry-point' : ''
|
|
328
|
+
].filter(Boolean).join(' ');
|
|
317
329
|
const iconHtml = node.entry.isMarkdown
|
|
318
330
|
? MARKDOWN_ICON_HTML
|
|
319
|
-
:
|
|
331
|
+
: isManifest
|
|
320
332
|
? MANIFEST_ICON_HTML
|
|
321
333
|
: isImageFile(node.entry.path)
|
|
322
334
|
? IMAGE_ICON_HTML
|
|
323
335
|
: FILE_ICON_HTML;
|
|
324
|
-
const orphanBtnHtml = isOrphaned && allowOrphanActions ? `
|
|
336
|
+
const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
|
|
325
337
|
<span class="nav-orphan-button" role="button" tabindex="0"
|
|
326
338
|
title="Orphaned asset" aria-label="Orphaned asset actions"
|
|
327
339
|
data-orphan-path="${safePath}">
|
|
328
340
|
${ORPHAN_ICON_HTML}
|
|
329
341
|
</span>` : '';
|
|
342
|
+
const draggable = options.allowDrag && !isManifest;
|
|
330
343
|
return `<button type="button" class="${classes}" title="${title}"
|
|
331
|
-
data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}">
|
|
344
|
+
data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
|
|
332
345
|
<span class="nav-caret"></span>
|
|
333
346
|
<span class="nav-file-icon ${iconKind}">${iconHtml}</span>
|
|
334
347
|
${orphanBtnHtml}
|
|
335
348
|
<span class="nav-label">${safeName}</span>
|
|
336
349
|
</button>`;
|
|
337
350
|
}
|
|
338
|
-
const children = node.children.map(c => renderNavNode(c, state,
|
|
339
|
-
|
|
340
|
-
|
|
351
|
+
const children = node.children.map(c => renderNavNode(c, state, options)).join('');
|
|
352
|
+
const pending = options.pendingFolders.has(node.path.toLowerCase());
|
|
353
|
+
return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
|
|
354
|
+
<summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
|
|
341
355
|
<span class="nav-caret" aria-hidden="true"></span>
|
|
342
356
|
<span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
|
|
343
357
|
<span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
|
|
@@ -346,11 +360,43 @@ function renderNavNode(node, state, allowOrphanActions) {
|
|
|
346
360
|
<div class="nav-directory-children">${children}</div>
|
|
347
361
|
</details>`;
|
|
348
362
|
}
|
|
363
|
+
// Inserts view-local pending (not yet saved) folders into the nav tree so they
|
|
364
|
+
// can be browsed and targeted before any file exists inside them.
|
|
365
|
+
function mergePendingFolders(nodes, pendingPaths) {
|
|
366
|
+
if (pendingPaths.size === 0) {
|
|
367
|
+
return nodes;
|
|
368
|
+
}
|
|
369
|
+
const result = nodes.map(cloneNavNode);
|
|
370
|
+
for (const path of pendingPaths) {
|
|
371
|
+
let children = result;
|
|
372
|
+
let prefix = '';
|
|
373
|
+
for (const segment of path.split('/').filter(Boolean)) {
|
|
374
|
+
prefix = prefix ? `${prefix}/${segment}` : segment;
|
|
375
|
+
let dir = children.find((n) => !n.entry && n.path.toLowerCase() === prefix.toLowerCase());
|
|
376
|
+
if (!dir) {
|
|
377
|
+
dir = { name: segment, path: prefix, children: [] };
|
|
378
|
+
children.push(dir);
|
|
379
|
+
children.sort(navNodeOrder);
|
|
380
|
+
}
|
|
381
|
+
children = dir.children;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
function cloneNavNode(node) {
|
|
387
|
+
return { ...node, children: node.children.map(cloneNavNode) };
|
|
388
|
+
}
|
|
389
|
+
function navNodeOrder(a, b) {
|
|
390
|
+
if (a.entry && !b.entry)
|
|
391
|
+
return -1;
|
|
392
|
+
if (!a.entry && b.entry)
|
|
393
|
+
return 1;
|
|
394
|
+
return a.name.localeCompare(b.name);
|
|
395
|
+
}
|
|
349
396
|
export class MdzipWorkspaceView {
|
|
350
397
|
constructor(container, options = {}) {
|
|
351
398
|
this.workspace = null;
|
|
352
399
|
this.unsub = null;
|
|
353
|
-
this.pendingOrphanPath = null;
|
|
354
400
|
this.layout = 'split';
|
|
355
401
|
this.navVisible = true;
|
|
356
402
|
this.zoom = 1;
|
|
@@ -362,7 +408,14 @@ export class MdzipWorkspaceView {
|
|
|
362
408
|
this.navPaneWidth = 280;
|
|
363
409
|
this.splitRatio = 0.5;
|
|
364
410
|
this.resizing = false;
|
|
365
|
-
this.
|
|
411
|
+
this.navMenuState = null;
|
|
412
|
+
this.nameDialogState = null;
|
|
413
|
+
this.deleteDialogState = null;
|
|
414
|
+
this.pendingNewFolders = new Set();
|
|
415
|
+
this.pendingReplacePath = null;
|
|
416
|
+
this.conversionHookPending = false;
|
|
417
|
+
this.dragSourcePath = null;
|
|
418
|
+
this.dragOverElement = null;
|
|
366
419
|
this.tooltipState = null;
|
|
367
420
|
this.tooltipShowTimer = null;
|
|
368
421
|
this.tooltipHideTimer = null;
|
|
@@ -424,7 +477,16 @@ export class MdzipWorkspaceView {
|
|
|
424
477
|
this.elMetadataList = q('[data-ref="metadata-list"]');
|
|
425
478
|
this.elConversionDialog = q('[data-ref="conversion-dialog"]');
|
|
426
479
|
this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
|
|
427
|
-
this.
|
|
480
|
+
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
481
|
+
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
482
|
+
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
483
|
+
this.elNameInput = q('[data-ref="name-input"]');
|
|
484
|
+
this.elNameValidation = q('[data-ref="name-validation"]');
|
|
485
|
+
this.elNameConfirmBtn = q('[data-ref="name-confirm-btn"]');
|
|
486
|
+
this.elDeleteDialog = q('[data-ref="delete-dialog"]');
|
|
487
|
+
this.elDeleteDialogText = q('[data-ref="delete-dialog-text"]');
|
|
488
|
+
this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
|
|
489
|
+
this.elReplaceInput = q('[data-ref="replace-input"]');
|
|
428
490
|
this.elTooltip = q('[data-ref="tooltip"]');
|
|
429
491
|
this.elEmptyState = q('[data-ref="empty-state"]');
|
|
430
492
|
this.prepareTooltips();
|
|
@@ -476,9 +538,21 @@ export class MdzipWorkspaceView {
|
|
|
476
538
|
*
|
|
477
539
|
* Assets must expose either `readDataUri` or `readBytes` so that subsequent
|
|
478
540
|
* ZIP rebuilds (e.g. on paste or asset removal) can read their bytes.
|
|
541
|
+
* Since 1.2.7, non-entry-point documents are lazy when the workspace was
|
|
542
|
+
* opened with `includeLazyDocumentReaders`: `text` is `''`, `isLazy` is
|
|
543
|
+
* `true`, and the content is only reachable via the `readText()` closure.
|
|
479
544
|
* Fields present at runtime but absent from the TypeScript interface —
|
|
480
545
|
* `validation`, `orphanedAssets`, and `asset.kind` — must be preserved on the
|
|
481
546
|
* workspace object or operations that depend on them will fail.
|
|
547
|
+
*
|
|
548
|
+
* WARNING: `readText`, `readBytes`, and `readDataUri` are closures and do
|
|
549
|
+
* not survive serialization boundaries such as `postMessage` to a webview.
|
|
550
|
+
* Hosts that serialize the workspace must materialize them first — resolve
|
|
551
|
+
* `readText()` into `text` for each document and `readDataUri()` into a data
|
|
552
|
+
* field for each asset — or rehydrate the reader functions on the far side.
|
|
553
|
+
* A document that arrives with `isLazy: true`, empty `text`, and no
|
|
554
|
+
* `readText` makes opening or archive rebuilds throw
|
|
555
|
+
* `ERR_LAZY_TEXT_UNAVAILABLE` instead of silently producing an empty file.
|
|
482
556
|
*/
|
|
483
557
|
async openWorkspace(workspace, options = {}) {
|
|
484
558
|
this.unsub?.();
|
|
@@ -523,6 +597,18 @@ export class MdzipWorkspaceView {
|
|
|
523
597
|
async removeAsset(archivePath, options) {
|
|
524
598
|
return this.workspace?.removeAsset(archivePath, options) ?? false;
|
|
525
599
|
}
|
|
600
|
+
async removeFile(archivePath) {
|
|
601
|
+
return this.workspace?.removeFile(archivePath) ?? false;
|
|
602
|
+
}
|
|
603
|
+
async renameFile(oldPath, newPath) {
|
|
604
|
+
return this.workspace?.renameFile(oldPath, newPath) ?? false;
|
|
605
|
+
}
|
|
606
|
+
async setEntryPoint(archivePath) {
|
|
607
|
+
return this.workspace?.setEntryPoint(archivePath) ?? false;
|
|
608
|
+
}
|
|
609
|
+
async setCoverImage(archivePath) {
|
|
610
|
+
return this.workspace?.setCoverImage(archivePath) ?? false;
|
|
611
|
+
}
|
|
526
612
|
listAssets() {
|
|
527
613
|
return this.workspace?.listAssets() ?? [];
|
|
528
614
|
}
|
|
@@ -605,6 +691,7 @@ export class MdzipWorkspaceView {
|
|
|
605
691
|
markdown(),
|
|
606
692
|
syntaxHighlighting(mdzipMarkdownHighlight),
|
|
607
693
|
EditorView.lineWrapping,
|
|
694
|
+
dropCursor(),
|
|
608
695
|
mdzipEditorTheme,
|
|
609
696
|
this.readOnlyCompartment.of(EditorState.readOnly.of(mode === 'read-only')),
|
|
610
697
|
EditorView.updateListener.of((update) => {
|
|
@@ -625,6 +712,36 @@ export class MdzipWorkspaceView {
|
|
|
625
712
|
void self.handlePaste(clipEvent);
|
|
626
713
|
return true;
|
|
627
714
|
}
|
|
715
|
+
},
|
|
716
|
+
dragover(event) {
|
|
717
|
+
if (!self.canAcceptEditorDrop()) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const types = event.dataTransfer?.types;
|
|
721
|
+
if (types?.includes('application/x-mdzip-path') || types?.includes('Files')) {
|
|
722
|
+
event.preventDefault();
|
|
723
|
+
if (event.dataTransfer) {
|
|
724
|
+
event.dataTransfer.dropEffect = 'copy';
|
|
725
|
+
}
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
},
|
|
729
|
+
drop(event) {
|
|
730
|
+
if (!self.canAcceptEditorDrop()) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
const archivePath = event.dataTransfer?.getData('application/x-mdzip-path');
|
|
734
|
+
if (archivePath) {
|
|
735
|
+
event.preventDefault();
|
|
736
|
+
self.insertArchiveLinkAtCoords(archivePath, event.clientX, event.clientY);
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
const file = event.dataTransfer?.files?.[0];
|
|
740
|
+
if (file && (file.type.startsWith('image/') || /\.(png|jpe?g|gif|webp|svg)$/i.test(file.name))) {
|
|
741
|
+
event.preventDefault();
|
|
742
|
+
void self.handleEditorImageDrop(file, event.clientX, event.clientY);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
628
745
|
}
|
|
629
746
|
}),
|
|
630
747
|
],
|
|
@@ -733,11 +850,19 @@ export class MdzipWorkspaceView {
|
|
|
733
850
|
this.elRoot.classList.toggle('navigation-pane-visible', showNavigationPane);
|
|
734
851
|
this.elNavPane.classList.toggle('hidden', !showNavigationPane);
|
|
735
852
|
this.elNavResizer.classList.toggle('hidden', !showNavigationPane);
|
|
853
|
+
this.prunePendingFolders(snapshot);
|
|
736
854
|
const navTree = snapshot.sourceFormat === 'mdz'
|
|
737
|
-
? buildMdzipNavTree(snapshot.content.paths)
|
|
855
|
+
? mergePendingFolders(buildMdzipNavTree(snapshot.content.paths), this.pendingNewFolders)
|
|
738
856
|
: [];
|
|
739
857
|
const allowOrphanActions = this.controlPolicy.orphanActions && snapshot.mode !== 'read-only';
|
|
740
|
-
|
|
858
|
+
const allowFileActions = this.allowFileActions(snapshot);
|
|
859
|
+
const navRenderOptions = {
|
|
860
|
+
allowOrphanActions,
|
|
861
|
+
allowFileActions,
|
|
862
|
+
allowDrag: snapshot.mode !== 'read-only' && snapshot.sourceFormat === 'mdz',
|
|
863
|
+
pendingFolders: new Set([...this.pendingNewFolders].map((path) => path.toLowerCase()))
|
|
864
|
+
};
|
|
865
|
+
this.elNavTree.innerHTML = navTree.map(n => renderNavNode(n, snapshot, navRenderOptions)).join('');
|
|
741
866
|
this.prepareTooltips();
|
|
742
867
|
if (this.cmEditor) {
|
|
743
868
|
this.updatingCm = true;
|
|
@@ -773,27 +898,93 @@ export class MdzipWorkspaceView {
|
|
|
773
898
|
}
|
|
774
899
|
this.elConversionDialog.hidden = this.conversionAction === null;
|
|
775
900
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
776
|
-
if (
|
|
777
|
-
this.
|
|
778
|
-
|
|
901
|
+
if (this.navMenuState) {
|
|
902
|
+
const items = this.navMenuItems(this.navMenuState.target, snapshot);
|
|
903
|
+
if (items.length === 0) {
|
|
904
|
+
this.navMenuState = null;
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
this.elNavMenu.innerHTML = items
|
|
908
|
+
.map((item) => item === null
|
|
909
|
+
? '<div class="nav-menu-separator" role="separator"></div>'
|
|
910
|
+
: `<button type="button" role="menuitem" data-menu-action="${escapeHtml(item.action)}">${escapeHtml(item.label)}</button>`)
|
|
911
|
+
.join('');
|
|
912
|
+
this.elNavMenu.hidden = false;
|
|
913
|
+
const rect = this.elNavMenu.getBoundingClientRect();
|
|
914
|
+
const win = this.elRoot.ownerDocument.defaultView ?? window;
|
|
915
|
+
const x = Math.max(4, Math.min(this.navMenuState.x, win.innerWidth - rect.width - 8));
|
|
916
|
+
const y = Math.max(4, Math.min(this.navMenuState.y, win.innerHeight - rect.height - 8));
|
|
917
|
+
this.elNavMenu.style.left = `${x}px`;
|
|
918
|
+
this.elNavMenu.style.top = `${y}px`;
|
|
919
|
+
}
|
|
779
920
|
}
|
|
780
|
-
if (this.
|
|
781
|
-
this.
|
|
782
|
-
this.elOrphanMenu.style.left = `${this.orphanMenuState.x}px`;
|
|
783
|
-
this.elOrphanMenu.style.top = `${this.orphanMenuState.y}px`;
|
|
921
|
+
if (!this.navMenuState) {
|
|
922
|
+
this.elNavMenu.hidden = true;
|
|
784
923
|
}
|
|
785
|
-
|
|
786
|
-
|
|
924
|
+
this.elNameDialog.hidden = this.nameDialogState === null;
|
|
925
|
+
if (this.nameDialogState) {
|
|
926
|
+
const headings = {
|
|
927
|
+
'new-file': 'New Markdown File',
|
|
928
|
+
'new-folder': 'New Folder',
|
|
929
|
+
'rename': 'Rename File'
|
|
930
|
+
};
|
|
931
|
+
const confirms = {
|
|
932
|
+
'new-file': 'Create',
|
|
933
|
+
'new-folder': 'Create',
|
|
934
|
+
'rename': 'Rename'
|
|
935
|
+
};
|
|
936
|
+
this.elNameDialogHeading.textContent = headings[this.nameDialogState.mode];
|
|
937
|
+
this.elNameConfirmBtn.textContent = confirms[this.nameDialogState.mode];
|
|
938
|
+
if (this.elNameInput.value !== this.nameDialogState.value) {
|
|
939
|
+
this.elNameInput.value = this.nameDialogState.value;
|
|
940
|
+
}
|
|
941
|
+
this.elNameValidation.hidden = !this.nameDialogState.error;
|
|
942
|
+
this.elNameValidation.textContent = this.nameDialogState.error;
|
|
943
|
+
this.elNameConfirmBtn.disabled = Boolean(this.nameDialogState.error);
|
|
944
|
+
}
|
|
945
|
+
this.elDeleteDialog.hidden = this.deleteDialogState === null;
|
|
946
|
+
if (this.deleteDialogState) {
|
|
947
|
+
this.elDeleteDialogText.textContent =
|
|
948
|
+
`Delete "${this.deleteDialogState.path}" from the archive? This cannot be undone.`;
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
allowFileActions(snapshot) {
|
|
952
|
+
return this.controlPolicy.fileActions
|
|
953
|
+
&& snapshot.mode !== 'read-only'
|
|
954
|
+
&& snapshot.sourceFormat === 'mdz';
|
|
955
|
+
}
|
|
956
|
+
// Drops pending folders that now contain at least one archive file (the
|
|
957
|
+
// path became real) so they render as normal directories.
|
|
958
|
+
prunePendingFolders(snapshot) {
|
|
959
|
+
if (this.pendingNewFolders.size === 0 || snapshot.sourceFormat !== 'mdz') {
|
|
960
|
+
this.pendingNewFolders.clear();
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const lowerPaths = snapshot.content.paths.map((entry) => entry.path.toLowerCase());
|
|
964
|
+
for (const folder of [...this.pendingNewFolders]) {
|
|
965
|
+
if (lowerPaths.some((path) => path.startsWith(`${folder.toLowerCase()}/`))) {
|
|
966
|
+
this.pendingNewFolders.delete(folder);
|
|
967
|
+
}
|
|
787
968
|
}
|
|
788
969
|
}
|
|
789
970
|
attachEvents() {
|
|
790
971
|
const doc = this.elRoot.ownerDocument;
|
|
791
972
|
doc.addEventListener('click', () => {
|
|
792
973
|
this.closeFormatMenus();
|
|
793
|
-
if (this.zoomOpen || this.
|
|
974
|
+
if (this.zoomOpen || this.navMenuState) {
|
|
794
975
|
this.zoomOpen = false;
|
|
795
|
-
this.
|
|
796
|
-
this.
|
|
976
|
+
this.navMenuState = null;
|
|
977
|
+
this.render();
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
doc.addEventListener('keydown', (e) => {
|
|
981
|
+
if (e.key !== 'Escape') {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (this.navMenuState || this.deleteDialogState || this.nameDialogState) {
|
|
985
|
+
this.navMenuState = null;
|
|
986
|
+
this.deleteDialogState = null;
|
|
987
|
+
this.nameDialogState = null;
|
|
797
988
|
this.render();
|
|
798
989
|
}
|
|
799
990
|
});
|
|
@@ -810,6 +1001,13 @@ export class MdzipWorkspaceView {
|
|
|
810
1001
|
}
|
|
811
1002
|
this.navVisible = !this.navVisible;
|
|
812
1003
|
this.render();
|
|
1004
|
+
if (this.navVisible && this.workspace) {
|
|
1005
|
+
void this.workspace.ensureOrphanedAssetsAnalyzed().then((updated) => {
|
|
1006
|
+
if (updated) {
|
|
1007
|
+
this.render();
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
813
1011
|
});
|
|
814
1012
|
this.elTitleBtn.addEventListener('click', () => {
|
|
815
1013
|
const snapshot = this.workspace?.snapshot();
|
|
@@ -861,8 +1059,8 @@ export class MdzipWorkspaceView {
|
|
|
861
1059
|
.addEventListener('click', () => this.setZoom(this.zoom + 0.1));
|
|
862
1060
|
this.elZoomPopover.querySelector('[data-action="zoom-reset"]')
|
|
863
1061
|
.addEventListener('click', () => this.setZoom(1));
|
|
864
|
-
this.elDarkThemeBtn.addEventListener('click', () => this.
|
|
865
|
-
this.elLightThemeBtn.addEventListener('click', () => this.
|
|
1062
|
+
this.elDarkThemeBtn.addEventListener('click', () => this.setColorSchemeFromToolbar('dark'));
|
|
1063
|
+
this.elLightThemeBtn.addEventListener('click', () => this.setColorSchemeFromToolbar('light'));
|
|
866
1064
|
this.elEditToolbar.addEventListener('click', (event) => {
|
|
867
1065
|
const menuToggle = event.target
|
|
868
1066
|
.closest('[data-format-menu-toggle]');
|
|
@@ -926,7 +1124,7 @@ export class MdzipWorkspaceView {
|
|
|
926
1124
|
}
|
|
927
1125
|
e.preventDefault();
|
|
928
1126
|
e.stopPropagation();
|
|
929
|
-
this.
|
|
1127
|
+
this.showNavMenuForPath(orphanBtn.getAttribute('data-orphan-path'), e);
|
|
930
1128
|
return;
|
|
931
1129
|
}
|
|
932
1130
|
const navFile = target.closest('[data-nav-path]');
|
|
@@ -934,21 +1132,25 @@ export class MdzipWorkspaceView {
|
|
|
934
1132
|
void this.openPath(navFile.getAttribute('data-nav-path'));
|
|
935
1133
|
}
|
|
936
1134
|
});
|
|
937
|
-
this.
|
|
938
|
-
const
|
|
939
|
-
if (
|
|
940
|
-
|
|
941
|
-
this.showOrphanMenu(navFile.getAttribute('data-nav-path'), e);
|
|
1135
|
+
this.elNavPane.addEventListener('contextmenu', (e) => {
|
|
1136
|
+
const target = this.navMenuTargetFromElement(e.target);
|
|
1137
|
+
if (!target) {
|
|
1138
|
+
return;
|
|
942
1139
|
}
|
|
1140
|
+
e.preventDefault();
|
|
1141
|
+
e.stopPropagation();
|
|
1142
|
+
this.showNavMenu(target, e);
|
|
943
1143
|
});
|
|
944
1144
|
this.elNavTree.addEventListener('keydown', (e) => {
|
|
945
1145
|
if (e.key === 'Enter') {
|
|
946
1146
|
const orphanBtn = e.target.closest('[data-orphan-path]');
|
|
947
1147
|
if (this.controlPolicy.orphanActions && orphanBtn) {
|
|
948
|
-
|
|
1148
|
+
e.preventDefault();
|
|
1149
|
+
this.showNavMenuForPath(orphanBtn.getAttribute('data-orphan-path'), e);
|
|
949
1150
|
}
|
|
950
1151
|
}
|
|
951
1152
|
});
|
|
1153
|
+
this.attachNavDragAndDrop();
|
|
952
1154
|
this.elNavResizer.addEventListener('pointerdown', (e) => {
|
|
953
1155
|
if (e.button !== 0) {
|
|
954
1156
|
return;
|
|
@@ -1032,11 +1234,56 @@ export class MdzipWorkspaceView {
|
|
|
1032
1234
|
this.elConversionConfirmBtn.addEventListener('click', () => {
|
|
1033
1235
|
void this.confirmMdzConversion();
|
|
1034
1236
|
});
|
|
1035
|
-
this.
|
|
1036
|
-
|
|
1237
|
+
this.elNavMenu.addEventListener('click', (e) => {
|
|
1238
|
+
e.stopPropagation();
|
|
1239
|
+
const item = e.target.closest('[data-menu-action]');
|
|
1240
|
+
if (item) {
|
|
1241
|
+
void this.handleNavMenuAction(item.dataset['menuAction'] ?? '');
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
this.elNameInput.addEventListener('input', () => {
|
|
1245
|
+
if (!this.nameDialogState) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
this.nameDialogState.value = this.elNameInput.value;
|
|
1249
|
+
this.nameDialogState.error = this.validateNameDialog(this.nameDialogState);
|
|
1250
|
+
this.elNameValidation.hidden = !this.nameDialogState.error;
|
|
1251
|
+
this.elNameValidation.textContent = this.nameDialogState.error;
|
|
1252
|
+
this.elNameConfirmBtn.disabled = Boolean(this.nameDialogState.error);
|
|
1253
|
+
});
|
|
1254
|
+
this.elNameInput.addEventListener('keydown', (e) => {
|
|
1255
|
+
if (e.key === 'Enter') {
|
|
1256
|
+
void this.confirmNameDialog();
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
this.elNameDialog.querySelector('[data-action="cancel-name"]')
|
|
1037
1260
|
.addEventListener('click', () => {
|
|
1038
|
-
|
|
1039
|
-
|
|
1261
|
+
this.nameDialogState = null;
|
|
1262
|
+
this.render();
|
|
1263
|
+
});
|
|
1264
|
+
this.elNameConfirmBtn.addEventListener('click', () => {
|
|
1265
|
+
void this.confirmNameDialog();
|
|
1266
|
+
});
|
|
1267
|
+
this.elDeleteDialog.querySelector('[data-action="cancel-delete"]')
|
|
1268
|
+
.addEventListener('click', () => {
|
|
1269
|
+
this.deleteDialogState = null;
|
|
1270
|
+
this.render();
|
|
1271
|
+
});
|
|
1272
|
+
this.elDeleteConfirmBtn.addEventListener('click', () => {
|
|
1273
|
+
const path = this.deleteDialogState?.path;
|
|
1274
|
+
this.deleteDialogState = null;
|
|
1275
|
+
this.render();
|
|
1276
|
+
if (path) {
|
|
1277
|
+
void this.deleteFile(path);
|
|
1278
|
+
}
|
|
1279
|
+
});
|
|
1280
|
+
this.elReplaceInput.addEventListener('change', () => {
|
|
1281
|
+
const file = this.elReplaceInput.files?.[0];
|
|
1282
|
+
const path = this.pendingReplacePath;
|
|
1283
|
+
this.elReplaceInput.value = '';
|
|
1284
|
+
this.pendingReplacePath = null;
|
|
1285
|
+
if (file && path) {
|
|
1286
|
+
void this.replaceFileFromPicker(path, file);
|
|
1040
1287
|
}
|
|
1041
1288
|
});
|
|
1042
1289
|
this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
|
|
@@ -1245,6 +1492,30 @@ export class MdzipWorkspaceView {
|
|
|
1245
1492
|
if (!snapshot || snapshot.mode === 'read-only' || snapshot.sourceFormat !== 'markdown') {
|
|
1246
1493
|
return;
|
|
1247
1494
|
}
|
|
1495
|
+
const hook = this.options.onConversionRequested;
|
|
1496
|
+
if (!hook) {
|
|
1497
|
+
this.openConversionDialog(action);
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (this.conversionHookPending) {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
this.conversionHookPending = true;
|
|
1504
|
+
void Promise.resolve()
|
|
1505
|
+
.then(() => hook(action))
|
|
1506
|
+
.then((handled) => {
|
|
1507
|
+
this.conversionHookPending = false;
|
|
1508
|
+
if (!handled) {
|
|
1509
|
+
this.openConversionDialog(action);
|
|
1510
|
+
}
|
|
1511
|
+
})
|
|
1512
|
+
.catch((error) => {
|
|
1513
|
+
this.conversionHookPending = false;
|
|
1514
|
+
this.options.onFailed?.(error);
|
|
1515
|
+
this.openConversionDialog(action);
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
openConversionDialog(action) {
|
|
1248
1519
|
this.conversionAction = action;
|
|
1249
1520
|
this.render();
|
|
1250
1521
|
requestAnimationFrame(() => this.elConversionConfirmBtn.focus());
|
|
@@ -1520,7 +1791,13 @@ export class MdzipWorkspaceView {
|
|
|
1520
1791
|
}
|
|
1521
1792
|
try {
|
|
1522
1793
|
const snapshot = event.snapshot;
|
|
1523
|
-
|
|
1794
|
+
// Manifest-only changes are delegated to onManifestChanged when the
|
|
1795
|
+
// host registered it: exporting bytes here would force a full archive
|
|
1796
|
+
// rebuild on workspaces opened without archiveBytes.
|
|
1797
|
+
const delegatedToManifestHandler = this.options.onManifestChanged
|
|
1798
|
+
&& event.changes.length === 1
|
|
1799
|
+
&& event.changes[0] === 'manifest';
|
|
1800
|
+
if (this.options.onChanged && !delegatedToManifestHandler) {
|
|
1524
1801
|
const bytes = await this.workspace.exportBytes();
|
|
1525
1802
|
this.options.onChanged(bytes, snapshot);
|
|
1526
1803
|
}
|
|
@@ -1556,12 +1833,28 @@ export class MdzipWorkspaceView {
|
|
|
1556
1833
|
this.zoom = Math.max(0.5, Math.min(2.5, Math.round(value * 100) / 100));
|
|
1557
1834
|
this.render();
|
|
1558
1835
|
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Sets the active color scheme after construction.
|
|
1838
|
+
*
|
|
1839
|
+
* Use for host-driven theme synchronization — e.g. a VS Code webview
|
|
1840
|
+
* reacting to `data-vscode-theme-kind` changes via MutationObserver — so the
|
|
1841
|
+
* editor follows the host theme without being recreated (recreation would
|
|
1842
|
+
* destroy the CodeMirror instance and lose unsaved edits). No-op when the
|
|
1843
|
+
* scheme is unchanged. Does not fire `onColorSchemeChanged`, which only
|
|
1844
|
+
* reports user-initiated toggles from the built-in toolbar buttons.
|
|
1845
|
+
*/
|
|
1559
1846
|
setColorScheme(colorScheme) {
|
|
1560
1847
|
if (this.colorScheme === colorScheme) {
|
|
1561
1848
|
return;
|
|
1562
1849
|
}
|
|
1563
1850
|
this.colorScheme = colorScheme;
|
|
1564
1851
|
this.render();
|
|
1852
|
+
}
|
|
1853
|
+
setColorSchemeFromToolbar(colorScheme) {
|
|
1854
|
+
if (this.colorScheme === colorScheme) {
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
this.setColorScheme(colorScheme);
|
|
1565
1858
|
this.options.onColorSchemeChanged?.(colorScheme);
|
|
1566
1859
|
}
|
|
1567
1860
|
setLayout(requested) {
|
|
@@ -1569,43 +1862,531 @@ export class MdzipWorkspaceView {
|
|
|
1569
1862
|
this.layout = snapshot ? this.validLayoutForSnapshot(requested, snapshot) : requested;
|
|
1570
1863
|
this.render();
|
|
1571
1864
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1865
|
+
navMenuTargetFromElement(element) {
|
|
1866
|
+
const snapshot = this.workspace?.snapshot();
|
|
1867
|
+
if (!snapshot || snapshot.sourceFormat !== 'mdz' || !element) {
|
|
1868
|
+
return null;
|
|
1869
|
+
}
|
|
1870
|
+
const navFile = element.closest('[data-nav-path]');
|
|
1871
|
+
if (navFile) {
|
|
1872
|
+
return this.fileMenuTarget(navFile.getAttribute('data-nav-path'), snapshot);
|
|
1873
|
+
}
|
|
1874
|
+
const directory = element.closest('details[data-nav-dir]');
|
|
1875
|
+
if (directory) {
|
|
1876
|
+
return { kind: 'directory', path: directory.getAttribute('data-nav-dir') ?? '' };
|
|
1877
|
+
}
|
|
1878
|
+
return { kind: 'directory', path: '' };
|
|
1879
|
+
}
|
|
1880
|
+
fileMenuTarget(path, snapshot) {
|
|
1881
|
+
const entry = snapshot.content.paths.find((item) => item.path.toLowerCase() === path.toLowerCase());
|
|
1882
|
+
if (!entry) {
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
return {
|
|
1886
|
+
kind: 'file',
|
|
1887
|
+
path: entry.path,
|
|
1888
|
+
orphaned: snapshot.content.orphanedAssetPaths
|
|
1889
|
+
.some((orphan) => orphan.toLowerCase() === entry.path.toLowerCase()),
|
|
1890
|
+
isMarkdown: entry.isMarkdown,
|
|
1891
|
+
isEntryPoint: entry.path.toLowerCase() === snapshot.content.entryPoint.toLowerCase(),
|
|
1892
|
+
isImage: entry.isImage,
|
|
1893
|
+
isManifest: isMdzipManifestPath(entry.path)
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
showNavMenuForPath(path, event) {
|
|
1897
|
+
const snapshot = this.workspace?.snapshot();
|
|
1898
|
+
const target = snapshot ? this.fileMenuTarget(path, snapshot) : null;
|
|
1899
|
+
if (target) {
|
|
1900
|
+
this.showNavMenu(target, event);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
showNavMenu(target, event) {
|
|
1904
|
+
const snapshot = this.workspace?.snapshot();
|
|
1905
|
+
if (!snapshot || this.navMenuItems(target, snapshot).length === 0) {
|
|
1574
1906
|
return;
|
|
1575
1907
|
}
|
|
1576
1908
|
const bounds = event.target?.getBoundingClientRect();
|
|
1577
1909
|
const clientX = event instanceof MouseEvent ? event.clientX : (bounds?.left ?? 0);
|
|
1578
1910
|
const clientY = event instanceof MouseEvent ? event.clientY : (bounds?.bottom ?? 0);
|
|
1579
|
-
this.
|
|
1580
|
-
this.orphanMenuState = {
|
|
1581
|
-
path,
|
|
1582
|
-
x: Math.max(4, Math.min(clientX, window.innerWidth - 210)),
|
|
1583
|
-
y: Math.max(4, Math.min(clientY, window.innerHeight - 44))
|
|
1584
|
-
};
|
|
1911
|
+
this.navMenuState = { target, x: clientX, y: clientY };
|
|
1585
1912
|
this.render();
|
|
1586
1913
|
}
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1914
|
+
// Items for the nav context menu; null entries render as separators.
|
|
1915
|
+
navMenuItems(target, snapshot) {
|
|
1916
|
+
const canMutate = this.allowFileActions(snapshot);
|
|
1917
|
+
if (target.kind === 'directory') {
|
|
1918
|
+
if (!canMutate) {
|
|
1919
|
+
return [];
|
|
1920
|
+
}
|
|
1921
|
+
return [
|
|
1922
|
+
{ action: 'new-file', label: 'New .md File' },
|
|
1923
|
+
{ action: 'new-folder', label: 'New Folder' }
|
|
1924
|
+
];
|
|
1925
|
+
}
|
|
1926
|
+
if (target.isManifest) {
|
|
1927
|
+
return [{ action: 'download', label: 'Download' }];
|
|
1928
|
+
}
|
|
1929
|
+
const groups = [];
|
|
1930
|
+
if (canMutate) {
|
|
1931
|
+
const stateGroup = [];
|
|
1932
|
+
if (target.isMarkdown && !target.isEntryPoint) {
|
|
1933
|
+
stateGroup.push({ action: 'set-entry-point', label: 'Set as Entry Point' });
|
|
1934
|
+
}
|
|
1935
|
+
if (target.isImage) {
|
|
1936
|
+
const cover = snapshot.content.manifest?.cover;
|
|
1937
|
+
stateGroup.push(cover && cover.toLowerCase() === target.path.toLowerCase()
|
|
1938
|
+
? { action: 'remove-cover', label: 'Remove Cover Image' }
|
|
1939
|
+
: { action: 'set-cover', label: 'Set as Cover Image' });
|
|
1940
|
+
}
|
|
1941
|
+
if (stateGroup.length > 0) {
|
|
1942
|
+
groups.push(stateGroup);
|
|
1943
|
+
}
|
|
1590
1944
|
}
|
|
1591
|
-
|
|
1945
|
+
groups.push([{
|
|
1946
|
+
action: 'copy-link',
|
|
1947
|
+
label: target.isImage ? 'Copy Image Embed' : 'Copy Markdown Link'
|
|
1948
|
+
}]);
|
|
1949
|
+
if (canMutate) {
|
|
1950
|
+
const editGroup = [
|
|
1951
|
+
{ action: 'rename', label: 'Rename…' },
|
|
1952
|
+
{ action: 'duplicate', label: 'Duplicate' }
|
|
1953
|
+
];
|
|
1954
|
+
if (!target.isMarkdown) {
|
|
1955
|
+
editGroup.push({ action: 'replace', label: 'Replace…' });
|
|
1956
|
+
}
|
|
1957
|
+
groups.push(editGroup);
|
|
1958
|
+
}
|
|
1959
|
+
groups.push([{ action: 'download', label: 'Download' }]);
|
|
1960
|
+
if (canMutate && !target.isEntryPoint) {
|
|
1961
|
+
groups.push([{
|
|
1962
|
+
action: 'delete',
|
|
1963
|
+
label: target.orphaned ? 'Delete Orphaned Asset' : 'Delete…'
|
|
1964
|
+
}]);
|
|
1965
|
+
}
|
|
1966
|
+
return groups.flatMap((group, index) => index === 0 ? group : [null, ...group]);
|
|
1592
1967
|
}
|
|
1593
|
-
async
|
|
1594
|
-
const
|
|
1595
|
-
this.
|
|
1596
|
-
this.pendingOrphanPath = null;
|
|
1968
|
+
async handleNavMenuAction(action) {
|
|
1969
|
+
const state = this.navMenuState;
|
|
1970
|
+
this.navMenuState = null;
|
|
1597
1971
|
this.render();
|
|
1598
|
-
if (!
|
|
1972
|
+
if (!state) {
|
|
1599
1973
|
return;
|
|
1600
1974
|
}
|
|
1975
|
+
const target = state.target;
|
|
1601
1976
|
try {
|
|
1602
|
-
|
|
1977
|
+
switch (action) {
|
|
1978
|
+
case 'new-file':
|
|
1979
|
+
if (target.kind === 'directory') {
|
|
1980
|
+
this.openNameDialog({ mode: 'new-file', dir: target.path, value: 'untitled.md' });
|
|
1981
|
+
}
|
|
1982
|
+
break;
|
|
1983
|
+
case 'new-folder':
|
|
1984
|
+
if (target.kind === 'directory') {
|
|
1985
|
+
this.openNameDialog({ mode: 'new-folder', dir: target.path, value: 'new-folder' });
|
|
1986
|
+
}
|
|
1987
|
+
break;
|
|
1988
|
+
case 'rename':
|
|
1989
|
+
if (target.kind === 'file') {
|
|
1990
|
+
this.openNameDialog({ mode: 'rename', oldPath: target.path, value: target.path });
|
|
1991
|
+
}
|
|
1992
|
+
break;
|
|
1993
|
+
case 'delete':
|
|
1994
|
+
if (target.kind === 'file') {
|
|
1995
|
+
if (target.orphaned) {
|
|
1996
|
+
await this.deleteFile(target.path);
|
|
1997
|
+
}
|
|
1998
|
+
else {
|
|
1999
|
+
this.deleteDialogState = { path: target.path };
|
|
2000
|
+
this.render();
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
break;
|
|
2004
|
+
case 'set-entry-point':
|
|
2005
|
+
if (target.kind === 'file') {
|
|
2006
|
+
await this.workspace?.setEntryPoint(target.path);
|
|
2007
|
+
this.render();
|
|
2008
|
+
}
|
|
2009
|
+
break;
|
|
2010
|
+
case 'set-cover':
|
|
2011
|
+
if (target.kind === 'file') {
|
|
2012
|
+
await this.workspace?.setCoverImage(target.path);
|
|
2013
|
+
this.render();
|
|
2014
|
+
}
|
|
2015
|
+
break;
|
|
2016
|
+
case 'remove-cover':
|
|
2017
|
+
await this.workspace?.setCoverImage(null);
|
|
2018
|
+
this.render();
|
|
2019
|
+
break;
|
|
2020
|
+
case 'copy-link':
|
|
2021
|
+
if (target.kind === 'file') {
|
|
2022
|
+
await this.copyMarkdownLink(target);
|
|
2023
|
+
}
|
|
2024
|
+
break;
|
|
2025
|
+
case 'download':
|
|
2026
|
+
if (target.kind === 'file') {
|
|
2027
|
+
await this.downloadArchiveFile(target.path);
|
|
2028
|
+
}
|
|
2029
|
+
break;
|
|
2030
|
+
case 'duplicate':
|
|
2031
|
+
if (target.kind === 'file') {
|
|
2032
|
+
await this.duplicateFile(target.path);
|
|
2033
|
+
}
|
|
2034
|
+
break;
|
|
2035
|
+
case 'replace':
|
|
2036
|
+
if (target.kind === 'file') {
|
|
2037
|
+
this.pendingReplacePath = target.path;
|
|
2038
|
+
this.elReplaceInput.click();
|
|
2039
|
+
}
|
|
2040
|
+
break;
|
|
2041
|
+
default:
|
|
2042
|
+
break;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
catch (error) {
|
|
2046
|
+
this.options.onFailed?.(error);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
openNameDialog(state) {
|
|
2050
|
+
this.nameDialogState = {
|
|
2051
|
+
mode: state.mode,
|
|
2052
|
+
dir: state.dir ?? '',
|
|
2053
|
+
oldPath: state.oldPath ?? '',
|
|
2054
|
+
value: state.value,
|
|
2055
|
+
error: ''
|
|
2056
|
+
};
|
|
2057
|
+
this.render();
|
|
2058
|
+
requestAnimationFrame(() => {
|
|
2059
|
+
this.elNameInput.focus();
|
|
2060
|
+
const dot = state.mode === 'rename'
|
|
2061
|
+
? -1
|
|
2062
|
+
: this.elNameInput.value.lastIndexOf('.');
|
|
2063
|
+
this.elNameInput.setSelectionRange(0, dot > 0 ? dot : this.elNameInput.value.length);
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
// Returns '' when valid, otherwise the validation message to show.
|
|
2067
|
+
validateNameDialog(state) {
|
|
2068
|
+
const snapshot = this.workspace?.snapshot();
|
|
2069
|
+
if (!snapshot) {
|
|
2070
|
+
return 'No workspace loaded.';
|
|
2071
|
+
}
|
|
2072
|
+
const value = state.value.trim();
|
|
2073
|
+
if (!value) {
|
|
2074
|
+
return 'Name cannot be empty.';
|
|
2075
|
+
}
|
|
2076
|
+
if (state.mode !== 'rename' && /[\\/]/.test(value)) {
|
|
2077
|
+
return 'Name cannot contain slashes.';
|
|
2078
|
+
}
|
|
2079
|
+
const fullPath = state.mode === 'rename'
|
|
2080
|
+
? value
|
|
2081
|
+
: state.dir ? `${state.dir}/${value}` : value;
|
|
2082
|
+
const normalized = normalizeArchivePath(fullPath);
|
|
2083
|
+
if (!normalized) {
|
|
2084
|
+
return 'Not a valid archive path.';
|
|
2085
|
+
}
|
|
2086
|
+
if (normalized.toLowerCase() === 'manifest.json') {
|
|
2087
|
+
return 'That name is reserved for the package manifest.';
|
|
2088
|
+
}
|
|
2089
|
+
if (state.mode === 'new-folder') {
|
|
2090
|
+
return '';
|
|
2091
|
+
}
|
|
2092
|
+
const finalPath = state.mode === 'new-file' && !/\.[^/.]+$/.test(normalized)
|
|
2093
|
+
? `${normalized}.md`
|
|
2094
|
+
: normalized;
|
|
2095
|
+
const collision = snapshot.content.paths.some((entry) => entry.path.toLowerCase() === finalPath.toLowerCase()
|
|
2096
|
+
&& entry.path.toLowerCase() !== state.oldPath.toLowerCase());
|
|
2097
|
+
if (collision) {
|
|
2098
|
+
return `"${finalPath}" already exists.`;
|
|
2099
|
+
}
|
|
2100
|
+
return '';
|
|
2101
|
+
}
|
|
2102
|
+
async confirmNameDialog() {
|
|
2103
|
+
const state = this.nameDialogState;
|
|
2104
|
+
if (!state) {
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
state.error = this.validateNameDialog(state);
|
|
2108
|
+
if (state.error) {
|
|
2109
|
+
this.render();
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
this.nameDialogState = null;
|
|
2113
|
+
const value = state.value.trim();
|
|
2114
|
+
try {
|
|
2115
|
+
if (state.mode === 'new-folder') {
|
|
2116
|
+
const folderPath = normalizeArchivePath(state.dir ? `${state.dir}/${value}` : value);
|
|
2117
|
+
this.pendingNewFolders.add(folderPath);
|
|
2118
|
+
this.render();
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
if (state.mode === 'new-file') {
|
|
2122
|
+
let path = normalizeArchivePath(state.dir ? `${state.dir}/${value}` : value);
|
|
2123
|
+
if (!/\.[^/.]+$/.test(path)) {
|
|
2124
|
+
path = `${path}.md`;
|
|
2125
|
+
}
|
|
2126
|
+
const baseName = path.slice(path.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '');
|
|
2127
|
+
await this.workspace?.addAsset(path, new TextEncoder().encode(`# ${baseName}\n`));
|
|
2128
|
+
await this.openPath(path);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
// rename
|
|
2132
|
+
const renamed = await this.workspace?.renameFile(state.oldPath, value);
|
|
2133
|
+
if (!renamed) {
|
|
2134
|
+
this.nameDialogState = { ...state, error: 'Could not rename the file.' };
|
|
2135
|
+
}
|
|
2136
|
+
this.render();
|
|
2137
|
+
}
|
|
2138
|
+
catch (error) {
|
|
2139
|
+
this.render();
|
|
2140
|
+
this.options.onFailed?.(error);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
async deleteFile(path) {
|
|
2144
|
+
try {
|
|
2145
|
+
await this.workspace?.removeFile(path);
|
|
2146
|
+
this.render();
|
|
2147
|
+
}
|
|
2148
|
+
catch (error) {
|
|
2149
|
+
this.options.onFailed?.(error);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
// Builds a `[name](relative/path)` (or `![…]` for images) reference from the
|
|
2153
|
+
// currently open document to an archive path.
|
|
2154
|
+
markdownLinkSnippet(targetPath, isImage, snapshot) {
|
|
2155
|
+
const fromDir = snapshot.currentPath.includes('/')
|
|
2156
|
+
? snapshot.currentPath.slice(0, snapshot.currentPath.lastIndexOf('/'))
|
|
2157
|
+
: '';
|
|
2158
|
+
const relative = relativeArchivePath(fromDir, targetPath);
|
|
2159
|
+
const encoded = relative.split('/').map(encodeURIComponent).join('/');
|
|
2160
|
+
const name = targetPath.slice(targetPath.lastIndexOf('/') + 1);
|
|
2161
|
+
return isImage ? `` : `[${name}](${encoded})`;
|
|
2162
|
+
}
|
|
2163
|
+
async copyMarkdownLink(target) {
|
|
2164
|
+
const snapshot = this.workspace?.snapshot();
|
|
2165
|
+
if (!snapshot) {
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
const markdown = this.markdownLinkSnippet(target.path, target.isImage, snapshot);
|
|
2169
|
+
const clipboard = this.elRoot.ownerDocument.defaultView?.navigator.clipboard;
|
|
2170
|
+
if (!clipboard) {
|
|
2171
|
+
throw new Error('Clipboard access is unavailable in this context.');
|
|
2172
|
+
}
|
|
2173
|
+
await clipboard.writeText(markdown);
|
|
2174
|
+
}
|
|
2175
|
+
canAcceptEditorDrop() {
|
|
2176
|
+
const snapshot = this.workspace?.snapshot();
|
|
2177
|
+
return Boolean(snapshot
|
|
2178
|
+
&& this.cmEditor
|
|
2179
|
+
&& snapshot.mode !== 'read-only'
|
|
2180
|
+
&& snapshot.currentPathType === 'markdown');
|
|
2181
|
+
}
|
|
2182
|
+
// Inserts a markdown link/image embed for a nav-tree file dropped onto the
|
|
2183
|
+
// editor, at the document position under the pointer.
|
|
2184
|
+
insertArchiveLinkAtCoords(archivePath, x, y) {
|
|
2185
|
+
const editor = this.cmEditor;
|
|
2186
|
+
const snapshot = this.workspace?.snapshot();
|
|
2187
|
+
if (!editor || !snapshot || snapshot.sourceFormat !== 'mdz') {
|
|
2188
|
+
return;
|
|
2189
|
+
}
|
|
2190
|
+
const entry = snapshot.content.paths.find((item) => item.path.toLowerCase() === archivePath.toLowerCase());
|
|
2191
|
+
if (!entry || isMdzipManifestPath(entry.path)) {
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
const snippet = this.markdownLinkSnippet(entry.path, entry.isImage, snapshot);
|
|
2195
|
+
const pos = editor.posAtCoords({ x, y }) ?? editor.state.selection.main.head;
|
|
2196
|
+
editor.dispatch({
|
|
2197
|
+
changes: { from: pos, insert: snippet },
|
|
2198
|
+
selection: { anchor: pos + snippet.length }
|
|
2199
|
+
});
|
|
2200
|
+
editor.focus();
|
|
2201
|
+
}
|
|
2202
|
+
// An OS image file dropped onto the editor embeds it like a paste would,
|
|
2203
|
+
// anchored at the pointer position (or via the conversion dialog for plain
|
|
2204
|
+
// markdown sources).
|
|
2205
|
+
async handleEditorImageDrop(file, x, y) {
|
|
2206
|
+
try {
|
|
2207
|
+
if (this.workspace?.sourceFormat === 'markdown') {
|
|
2208
|
+
this.requestMdzConversion({ kind: 'image-file', file });
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
const editor = this.cmEditor;
|
|
2212
|
+
if (editor) {
|
|
2213
|
+
const pos = editor.posAtCoords({ x, y });
|
|
2214
|
+
if (pos !== null) {
|
|
2215
|
+
editor.dispatch({ selection: { anchor: pos } });
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
await this.insertImageFile(file);
|
|
2219
|
+
}
|
|
2220
|
+
catch (error) {
|
|
2221
|
+
this.options.onFailed?.(error);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
async downloadArchiveFile(path) {
|
|
2225
|
+
const bytes = await this.workspace?.readPathBytes(path);
|
|
2226
|
+
if (!bytes) {
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
2230
|
+
const fileName = path.slice(path.lastIndexOf('/') + 1);
|
|
2231
|
+
this.downloadSavedBlob(new Blob([buffer]), fileName);
|
|
2232
|
+
}
|
|
2233
|
+
async duplicateFile(path) {
|
|
2234
|
+
const workspace = this.workspace;
|
|
2235
|
+
const snapshot = workspace?.snapshot();
|
|
2236
|
+
if (!workspace || !snapshot) {
|
|
2237
|
+
return;
|
|
2238
|
+
}
|
|
2239
|
+
const bytes = await workspace.readPathBytes(path);
|
|
2240
|
+
if (!bytes) {
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2243
|
+
const existing = new Set(snapshot.content.paths.map((entry) => entry.path.toLowerCase()));
|
|
2244
|
+
const dot = path.lastIndexOf('.');
|
|
2245
|
+
const slash = path.lastIndexOf('/');
|
|
2246
|
+
const stem = dot > slash ? path.slice(0, dot) : path;
|
|
2247
|
+
const extension = dot > slash ? path.slice(dot) : '';
|
|
2248
|
+
let counter = 2;
|
|
2249
|
+
let candidate = `${stem}-${counter}${extension}`;
|
|
2250
|
+
while (existing.has(candidate.toLowerCase())) {
|
|
2251
|
+
counter += 1;
|
|
2252
|
+
candidate = `${stem}-${counter}${extension}`;
|
|
2253
|
+
}
|
|
2254
|
+
await workspace.addAsset(candidate, bytes);
|
|
2255
|
+
await this.openPath(candidate);
|
|
2256
|
+
}
|
|
2257
|
+
async replaceFileFromPicker(path, file) {
|
|
2258
|
+
try {
|
|
2259
|
+
await this.workspace?.replaceAsset(path, new Uint8Array(await file.arrayBuffer()));
|
|
2260
|
+
this.render();
|
|
2261
|
+
}
|
|
2262
|
+
catch (error) {
|
|
2263
|
+
this.options.onFailed?.(error);
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
attachNavDragAndDrop() {
|
|
2267
|
+
this.elNavTree.addEventListener('dragstart', (e) => {
|
|
2268
|
+
const navFile = e.target.closest('[data-nav-path][draggable="true"]');
|
|
2269
|
+
const snapshot = this.workspace?.snapshot();
|
|
2270
|
+
if (!navFile || !snapshot
|
|
2271
|
+
|| snapshot.mode === 'read-only' || snapshot.sourceFormat !== 'mdz') {
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
this.dragSourcePath = navFile.getAttribute('data-nav-path');
|
|
2275
|
+
e.dataTransfer?.setData('application/x-mdzip-path', this.dragSourcePath ?? '');
|
|
2276
|
+
if (e.dataTransfer) {
|
|
2277
|
+
// Must permit both effects: tree drops are 'move', editor drops are
|
|
2278
|
+
// 'copy'. A dropEffect outside effectAllowed makes the browser cancel
|
|
2279
|
+
// the drop without firing the drop event at all.
|
|
2280
|
+
e.dataTransfer.effectAllowed = 'copyMove';
|
|
2281
|
+
}
|
|
2282
|
+
});
|
|
2283
|
+
this.elNavTree.addEventListener('dragend', () => {
|
|
2284
|
+
this.dragSourcePath = null;
|
|
2285
|
+
this.setDragOverElement(null);
|
|
2286
|
+
});
|
|
2287
|
+
this.elNavPane.addEventListener('dragover', (e) => {
|
|
2288
|
+
const snapshot = this.workspace?.snapshot();
|
|
2289
|
+
if (!snapshot || !this.allowFileActions(snapshot)) {
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
const internal = this.dragSourcePath !== null
|
|
2293
|
+
|| (e.dataTransfer?.types.includes('application/x-mdzip-path') ?? false);
|
|
2294
|
+
const external = e.dataTransfer?.types.includes('Files') ?? false;
|
|
2295
|
+
if (!internal && !external) {
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
e.preventDefault();
|
|
2299
|
+
if (e.dataTransfer) {
|
|
2300
|
+
e.dataTransfer.dropEffect = internal ? 'move' : 'copy';
|
|
2301
|
+
}
|
|
2302
|
+
const directory = e.target.closest('details[data-nav-dir]');
|
|
2303
|
+
this.setDragOverElement(directory ?? this.elNavTree);
|
|
2304
|
+
});
|
|
2305
|
+
this.elNavPane.addEventListener('dragleave', (e) => {
|
|
2306
|
+
if (!this.elNavPane.contains(e.relatedTarget)) {
|
|
2307
|
+
this.setDragOverElement(null);
|
|
2308
|
+
}
|
|
2309
|
+
});
|
|
2310
|
+
this.elNavPane.addEventListener('drop', (e) => {
|
|
2311
|
+
const snapshot = this.workspace?.snapshot();
|
|
2312
|
+
this.setDragOverElement(null);
|
|
2313
|
+
if (!snapshot || !this.allowFileActions(snapshot)) {
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const directory = e.target.closest('details[data-nav-dir]');
|
|
2317
|
+
const targetDir = directory?.getAttribute('data-nav-dir') ?? '';
|
|
2318
|
+
const internalPath = e.dataTransfer?.getData('application/x-mdzip-path') || this.dragSourcePath;
|
|
2319
|
+
this.dragSourcePath = null;
|
|
2320
|
+
if (internalPath) {
|
|
2321
|
+
e.preventDefault();
|
|
2322
|
+
void this.moveFileToDirectory(internalPath, targetDir);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
const files = e.dataTransfer?.files;
|
|
2326
|
+
if (files && files.length > 0) {
|
|
2327
|
+
e.preventDefault();
|
|
2328
|
+
void this.addDroppedFiles(targetDir, Array.from(files));
|
|
2329
|
+
}
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2332
|
+
setDragOverElement(element) {
|
|
2333
|
+
if (this.dragOverElement === element) {
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
this.dragOverElement?.classList.remove('drag-over');
|
|
2337
|
+
this.dragOverElement = element;
|
|
2338
|
+
element?.classList.add('drag-over');
|
|
2339
|
+
}
|
|
2340
|
+
async moveFileToDirectory(path, targetDir) {
|
|
2341
|
+
const baseName = path.slice(path.lastIndexOf('/') + 1);
|
|
2342
|
+
const newPath = targetDir ? `${targetDir}/${baseName}` : baseName;
|
|
2343
|
+
if (newPath.toLowerCase() === path.toLowerCase()) {
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
try {
|
|
2347
|
+
await this.workspace?.renameFile(path, newPath);
|
|
1603
2348
|
this.render();
|
|
1604
2349
|
}
|
|
1605
2350
|
catch (error) {
|
|
1606
2351
|
this.options.onFailed?.(error);
|
|
1607
2352
|
}
|
|
1608
2353
|
}
|
|
2354
|
+
async addDroppedFiles(targetDir, files) {
|
|
2355
|
+
const workspace = this.workspace;
|
|
2356
|
+
if (!workspace) {
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
for (const file of files) {
|
|
2361
|
+
const normalized = normalizeArchivePath(targetDir ? `${targetDir}/${file.name}` : file.name);
|
|
2362
|
+
if (!normalized || normalized.toLowerCase() === 'manifest.json') {
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
const existing = new Set(workspace.snapshot().content.paths.map((entry) => entry.path.toLowerCase()));
|
|
2366
|
+
const dot = normalized.lastIndexOf('.');
|
|
2367
|
+
const slash = normalized.lastIndexOf('/');
|
|
2368
|
+
const stem = dot > slash ? normalized.slice(0, dot) : normalized;
|
|
2369
|
+
const extension = dot > slash ? normalized.slice(dot) : '';
|
|
2370
|
+
let candidate = normalized;
|
|
2371
|
+
let counter = 2;
|
|
2372
|
+
while (existing.has(candidate.toLowerCase())) {
|
|
2373
|
+
candidate = `${stem}-${counter}${extension}`;
|
|
2374
|
+
counter += 1;
|
|
2375
|
+
}
|
|
2376
|
+
await workspace.addAsset(candidate, new Uint8Array(await file.arrayBuffer()));
|
|
2377
|
+
}
|
|
2378
|
+
this.render();
|
|
2379
|
+
}
|
|
2380
|
+
catch (error) {
|
|
2381
|
+
this.options.onFailed?.(error);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
validLayoutForSnapshot(requested, snapshot) {
|
|
2385
|
+
if (requested === 'source' || requested === 'split') {
|
|
2386
|
+
return canShowSourceLayout(snapshot) ? requested : 'preview';
|
|
2387
|
+
}
|
|
2388
|
+
return requested;
|
|
2389
|
+
}
|
|
1609
2390
|
syncScrollFromPreview() {
|
|
1610
2391
|
if (this.syncing || !this.cmEditor || this.layout !== 'split') {
|
|
1611
2392
|
return;
|
|
@@ -1874,10 +2655,35 @@ const SHELL_HTML = `
|
|
|
1874
2655
|
</div>
|
|
1875
2656
|
</div>
|
|
1876
2657
|
|
|
1877
|
-
<div class="
|
|
1878
|
-
|
|
2658
|
+
<div class="title-dialog-backdrop" data-ref="name-dialog" hidden
|
|
2659
|
+
role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
|
|
2660
|
+
<div class="title-dialog">
|
|
2661
|
+
<h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
|
|
2662
|
+
<input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
|
|
2663
|
+
<p class="title-dialog-validation" data-ref="name-validation" hidden></p>
|
|
2664
|
+
<div class="title-dialog-actions">
|
|
2665
|
+
<button type="button" data-action="cancel-name">Cancel</button>
|
|
2666
|
+
<button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
|
|
2667
|
+
</div>
|
|
2668
|
+
</div>
|
|
2669
|
+
</div>
|
|
2670
|
+
|
|
2671
|
+
<div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
|
|
2672
|
+
role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
|
|
2673
|
+
<div class="title-dialog">
|
|
2674
|
+
<h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
|
|
2675
|
+
<p data-ref="delete-dialog-text"></p>
|
|
2676
|
+
<div class="title-dialog-actions">
|
|
2677
|
+
<button type="button" data-action="cancel-delete">Cancel</button>
|
|
2678
|
+
<button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
|
|
2679
|
+
</div>
|
|
2680
|
+
</div>
|
|
1879
2681
|
</div>
|
|
1880
2682
|
|
|
2683
|
+
<input type="file" data-ref="replace-input" hidden />
|
|
2684
|
+
|
|
2685
|
+
<div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
|
|
2686
|
+
|
|
1881
2687
|
<div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
|
|
1882
2688
|
|
|
1883
2689
|
<p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
|