@mdzip/editor 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, allowOrphanActions) {
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
- : safePath;
315
- const classes = ['nav-file', isCurrent ? 'current-entry' : '', isOrphaned ? 'orphaned-asset' : '']
316
- .filter(Boolean).join(' ');
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
- : isMdzipManifestPath(node.entry.path)
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, allowOrphanActions)).join('');
339
- return `<details class="nav-directory" open>
340
- <summary>
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.orphanMenuState = null;
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.elOrphanMenu = q('[data-ref="orphan-menu"]');
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
- this.elNavTree.innerHTML = navTree.map(n => renderNavNode(n, snapshot, allowOrphanActions)).join('');
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 (!allowOrphanActions) {
777
- this.orphanMenuState = null;
778
- this.pendingOrphanPath = null;
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.orphanMenuState) {
781
- this.elOrphanMenu.hidden = false;
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
- else {
786
- this.elOrphanMenu.hidden = true;
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.orphanMenuState) {
974
+ if (this.zoomOpen || this.navMenuState) {
794
975
  this.zoomOpen = false;
795
- this.orphanMenuState = null;
796
- this.pendingOrphanPath = null;
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.setColorScheme('dark'));
865
- this.elLightThemeBtn.addEventListener('click', () => this.setColorScheme('light'));
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.showOrphanMenu(orphanBtn.getAttribute('data-orphan-path'), e);
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.elNavTree.addEventListener('contextmenu', (e) => {
938
- const navFile = e.target.closest('[data-nav-path]');
939
- if (this.controlPolicy.orphanActions && navFile?.getAttribute('data-orphan') === 'true') {
940
- e.preventDefault();
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
- this.showOrphanMenu(orphanBtn.getAttribute('data-orphan-path'), e);
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.elOrphanMenu.addEventListener('click', (e) => e.stopPropagation());
1036
- this.elOrphanMenu.querySelector('[data-action="remove-orphan"]')
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
- if (this.controlPolicy.orphanActions) {
1039
- void this.removeOrphan();
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
- if (this.options.onChanged) {
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
- showOrphanMenu(path, event) {
1573
- if (!this.controlPolicy.orphanActions) {
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.pendingOrphanPath = path;
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
- validLayoutForSnapshot(requested, snapshot) {
1588
- if (requested === 'source' || requested === 'split') {
1589
- return canShowSourceLayout(snapshot) ? requested : 'preview';
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
- return requested;
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 removeOrphan() {
1594
- const path = this.pendingOrphanPath;
1595
- this.orphanMenuState = null;
1596
- this.pendingOrphanPath = null;
1968
+ async handleNavMenuAction(action) {
1969
+ const state = this.navMenuState;
1970
+ this.navMenuState = null;
1597
1971
  this.render();
1598
- if (!path) {
1972
+ if (!state) {
1599
1973
  return;
1600
1974
  }
1975
+ const target = state.target;
1601
1976
  try {
1602
- await this.workspace?.removeAsset(path, { requireOrphaned: true });
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})` : `[${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="orphan-context-menu" data-ref="orphan-menu" hidden role="menu">
1878
- <button type="button" role="menuitem" data-action="remove-orphan">Remove Orphaned Asset</button>
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>