@mdzip/editor 1.3.1 → 1.3.11

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.
Files changed (52) hide show
  1. package/README.md +200 -4
  2. package/dist/archive-utils.d.ts.map +1 -1
  3. package/dist/archive-utils.js +1 -34
  4. package/dist/archive-utils.js.map +1 -1
  5. package/dist/asset-cache.d.ts +92 -0
  6. package/dist/asset-cache.d.ts.map +1 -0
  7. package/dist/asset-cache.js +392 -0
  8. package/dist/asset-cache.js.map +1 -0
  9. package/dist/diff-view-css.d.ts +2 -0
  10. package/dist/diff-view-css.d.ts.map +1 -0
  11. package/dist/diff-view-css.js +49 -0
  12. package/dist/diff-view-css.js.map +1 -0
  13. package/dist/diff-view.d.ts +112 -0
  14. package/dist/diff-view.d.ts.map +1 -0
  15. package/dist/diff-view.js +573 -0
  16. package/dist/diff-view.js.map +1 -0
  17. package/dist/diff.d.ts +2 -1
  18. package/dist/diff.d.ts.map +1 -1
  19. package/dist/diff.js +3 -1
  20. package/dist/diff.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/library-info.d.ts +37 -0
  26. package/dist/library-info.d.ts.map +1 -0
  27. package/dist/library-info.js +46 -0
  28. package/dist/library-info.js.map +1 -0
  29. package/dist/mermaid.d.ts +46 -0
  30. package/dist/mermaid.d.ts.map +1 -0
  31. package/dist/mermaid.js +107 -0
  32. package/dist/mermaid.js.map +1 -0
  33. package/dist/preview.d.ts +10 -0
  34. package/dist/preview.d.ts.map +1 -0
  35. package/dist/preview.js +9 -0
  36. package/dist/preview.js.map +1 -0
  37. package/dist/rendering.d.ts +175 -2
  38. package/dist/rendering.d.ts.map +1 -1
  39. package/dist/rendering.js +132 -14
  40. package/dist/rendering.js.map +1 -1
  41. package/dist/view-css.d.ts.map +1 -1
  42. package/dist/view-css.js +133 -1
  43. package/dist/view-css.js.map +1 -1
  44. package/dist/view.d.ts +148 -1
  45. package/dist/view.d.ts.map +1 -1
  46. package/dist/view.js +805 -22
  47. package/dist/view.js.map +1 -1
  48. package/dist/workspace.d.ts +15 -0
  49. package/dist/workspace.d.ts.map +1 -1
  50. package/dist/workspace.js +42 -4
  51. package/dist/workspace.js.map +1 -1
  52. package/package.json +26 -4
package/dist/view.js CHANGED
@@ -6,13 +6,19 @@ 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
+ import { MdzipAssetSession, mdzipArchiveSourceId } from './asset-cache.js';
9
10
  import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
11
+ import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
10
12
  import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
11
13
  import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
14
+ import { MdzipRenderingService, defaultSafeMarkdownRenderer } from './rendering.js';
12
15
  import { WORKSPACE_CSS } from './view-css.js';
13
16
  const STYLE_ATTR = 'data-mdzip-ws-styles';
14
17
  const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
15
18
  const isImageFile = (path) => IMAGE_EXTENSIONS.test(path);
19
+ function isThenable(value) {
20
+ return typeof value?.then === 'function';
21
+ }
16
22
  const NAV_ICON_CLASS = 'nav-lucide-icon';
17
23
  const TOOLBAR_ICON_CLASS = 'toggle-icon';
18
24
  const MANIFEST_ICON_HTML = lucideIcon(FileBraces, NAV_ICON_CLASS);
@@ -396,6 +402,7 @@ function navNodeOrder(a, b) {
396
402
  export class MdzipWorkspaceView {
397
403
  constructor(container, options = {}) {
398
404
  this.workspace = null;
405
+ this.assetSession = null;
399
406
  this.unsub = null;
400
407
  this.layout = 'split';
401
408
  this.navVisible = true;
@@ -414,6 +421,12 @@ export class MdzipWorkspaceView {
414
421
  this.pendingNewFolders = new Set();
415
422
  this.pendingReplacePath = null;
416
423
  this.conversionHookPending = false;
424
+ this.conversionDocumentGeneration = 0;
425
+ // Monotonic token bumped on every openArchive/openWorkspace call. The async
426
+ // parse for a superseded call must not win the race and overwrite the latest
427
+ // input, so each call captures its token and discards its result once a newer
428
+ // open has started.
429
+ this.openGeneration = 0;
417
430
  this.dragSourcePath = null;
418
431
  this.dragOverElement = null;
419
432
  this.tooltipState = null;
@@ -423,6 +436,25 @@ export class MdzipWorkspaceView {
423
436
  this.readOnlyCompartment = new Compartment();
424
437
  this.updatingCm = false;
425
438
  this.syncing = false;
439
+ this.markdownExtensions = [];
440
+ this.entryRenderers = [];
441
+ this.renderingService = new MdzipRenderingService();
442
+ // Preview render memo: the preview pipeline only re-runs when one of these
443
+ // inputs actually changed, so unrelated snapshot renders (dialogs, nav,
444
+ // layout toggles) never reset preview DOM or re-run extension mounts.
445
+ this.previewMemo = null;
446
+ this.previewGeneration = 0;
447
+ this.previewAbort = null;
448
+ this.previewHandles = [];
449
+ // Whether the latest preview generation has finished mounting and hydrating
450
+ // its images; drives `whenRendered()` and gates the hydration callback.
451
+ this.previewHydrated = false;
452
+ this.renderedWaiters = [];
453
+ this.entryState = null;
454
+ // Negative match cache: with only non-matching renderers registered,
455
+ // matches() does not re-run on every snapshot render of the same entry.
456
+ this.entryMatchMissKey = null;
457
+ this.entryGeneration = 0;
426
458
  this.options = options;
427
459
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
428
460
  this.navigationMode = options.navigationMode ?? 'editor';
@@ -432,6 +464,10 @@ export class MdzipWorkspaceView {
432
464
  ? 'dark'
433
465
  : 'light');
434
466
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
467
+ this.markdownRenderer = options.markdownRenderer;
468
+ this.markdownExtensions = options.markdownExtensions ?? [];
469
+ this.entryRenderers = options.entryRenderers ?? [];
470
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
435
471
  injectStyles(container.ownerDocument);
436
472
  container.replaceChildren();
437
473
  container.innerHTML = SHELL_HTML;
@@ -468,6 +504,7 @@ export class MdzipWorkspaceView {
468
504
  this.elSplitResizer = q('[data-ref="split-resizer"]');
469
505
  this.elPreviewPane = q('[data-ref="preview-pane"]');
470
506
  this.elPreviewContent = q('[data-ref="preview-content"]');
507
+ this.elEntryPane = q('[data-ref="entry-pane"]');
471
508
  this.elTitleDialog = q('[data-ref="title-dialog"]');
472
509
  this.elTitleInput = q('[data-ref="title-input"]');
473
510
  this.elTitleValidation = q('[data-ref="title-validation"]');
@@ -475,6 +512,7 @@ export class MdzipWorkspaceView {
475
512
  this.elTitleResetBtn = q('[data-ref="title-reset-btn"]');
476
513
  this.elMetadataDialog = q('[data-ref="metadata-dialog"]');
477
514
  this.elMetadataList = q('[data-ref="metadata-list"]');
515
+ this.elLibraryList = q('[data-ref="library-list"]');
478
516
  this.elConversionDialog = q('[data-ref="conversion-dialog"]');
479
517
  this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
480
518
  this.elNavMenu = q('[data-ref="nav-menu"]');
@@ -504,20 +542,47 @@ export class MdzipWorkspaceView {
504
542
  await this.openArchive(bytes, options);
505
543
  }
506
544
  async openArchive(bytes, options = {}) {
545
+ const generation = ++this.openGeneration;
507
546
  this.unsub?.();
547
+ this.resetRenderingState();
508
548
  this.cmEditor?.destroy();
509
549
  this.cmEditor = null;
510
550
  this.workspace = null;
551
+ this.replaceAssetSession(null);
552
+ this.conversionDocumentGeneration += 1;
511
553
  try {
512
554
  const ws = await MdzipWorkspaceService.open(bytes, options);
555
+ if (generation !== this.openGeneration) {
556
+ // A newer open() started while this parse was in flight; discard this
557
+ // stale result so the latest input remains authoritative.
558
+ return;
559
+ }
513
560
  this.workspace = ws;
561
+ this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
562
+ cache: this.options.assetCache,
563
+ sourceId: () => mdzipArchiveSourceId(bytes),
564
+ onFailed: this.options.onFailed
565
+ }));
514
566
  const snap = ws.snapshot();
515
567
  if (snap.sourceFormat === 'markdown') {
516
568
  this.navVisible = false;
517
569
  }
518
570
  this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
519
- this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
571
+ await this.ensureCmEditor();
572
+ if (generation !== this.openGeneration) {
573
+ return;
574
+ }
520
575
  this.unsub = ws.subscribe((event) => {
576
+ if (event.changes.includes('asset')) {
577
+ this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
578
+ cache: this.options.assetCache,
579
+ sourceId: () => mdzipArchiveSourceId(event.snapshot.archiveBytes),
580
+ onFailed: this.options.onFailed
581
+ }));
582
+ }
583
+ if (event.changes.includes('workspace') || event.changes.includes('document')) {
584
+ this.conversionDocumentGeneration += 1;
585
+ }
521
586
  this.render();
522
587
  void this.notifyChanged(event);
523
588
  });
@@ -525,6 +590,9 @@ export class MdzipWorkspaceView {
525
590
  void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
526
591
  }
527
592
  catch (error) {
593
+ if (generation !== this.openGeneration) {
594
+ return;
595
+ }
528
596
  this.options.onFailed?.(error);
529
597
  }
530
598
  }
@@ -555,17 +623,46 @@ export class MdzipWorkspaceView {
555
623
  * `ERR_LAZY_TEXT_UNAVAILABLE` instead of silently producing an empty file.
556
624
  */
557
625
  async openWorkspace(workspace, options = {}) {
626
+ const generation = ++this.openGeneration;
558
627
  this.unsub?.();
628
+ this.resetRenderingState();
559
629
  this.cmEditor?.destroy();
560
630
  this.cmEditor = null;
561
631
  this.workspace = null;
632
+ this.replaceAssetSession(null);
633
+ this.conversionDocumentGeneration += 1;
562
634
  try {
563
635
  const ws = await MdzipWorkspaceService.openWorkspace(workspace, options);
636
+ if (generation !== this.openGeneration) {
637
+ // A newer open started while this parse was in flight; discard it.
638
+ return;
639
+ }
564
640
  this.workspace = ws;
641
+ this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
642
+ cache: this.options.assetCache,
643
+ sourceId: options.assetSourceId
644
+ ?? (options.archiveBytes ? () => mdzipArchiveSourceId(options.archiveBytes) : undefined),
645
+ onFailed: this.options.onFailed
646
+ }));
565
647
  const snap = ws.snapshot();
566
648
  this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
567
- this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
649
+ await this.ensureCmEditor();
650
+ if (generation !== this.openGeneration) {
651
+ return;
652
+ }
568
653
  this.unsub = ws.subscribe((event) => {
654
+ if (event.changes.includes('asset')) {
655
+ this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
656
+ cache: this.options.assetCache,
657
+ sourceId: event.snapshot.archiveBytes.length
658
+ ? () => mdzipArchiveSourceId(event.snapshot.archiveBytes)
659
+ : undefined,
660
+ onFailed: this.options.onFailed
661
+ }));
662
+ }
663
+ if (event.changes.includes('workspace') || event.changes.includes('document')) {
664
+ this.conversionDocumentGeneration += 1;
665
+ }
569
666
  this.render();
570
667
  void this.notifyChanged(event);
571
668
  });
@@ -573,6 +670,9 @@ export class MdzipWorkspaceView {
573
670
  void this.notifyChanged(this.initialWorkspaceEvent(ws.snapshot()));
574
671
  }
575
672
  catch (error) {
673
+ if (generation !== this.openGeneration) {
674
+ return;
675
+ }
576
676
  this.options.onFailed?.(error);
577
677
  }
578
678
  }
@@ -613,14 +713,18 @@ export class MdzipWorkspaceView {
613
713
  return this.workspace?.listAssets() ?? [];
614
714
  }
615
715
  canExecuteCommand(command) {
716
+ // Availability is currently uniform across commands; the parameter is
717
+ // kept so per-command policies stay a non-breaking change.
718
+ void command;
616
719
  const snapshot = this.workspace?.snapshot();
617
- if (!snapshot || !this.cmEditor || snapshot.mode === 'read-only'
720
+ if (!snapshot || snapshot.mode === 'read-only'
618
721
  || snapshot.currentPathType !== 'markdown') {
619
722
  return false;
620
723
  }
621
724
  return true;
622
725
  }
623
726
  async executeCommand(command, file) {
727
+ await this.ensureCmEditor(true);
624
728
  if (!this.canExecuteCommand(command)) {
625
729
  return false;
626
730
  }
@@ -660,12 +764,18 @@ export class MdzipWorkspaceView {
660
764
  this.cmEditor?.focus();
661
765
  }
662
766
  destroy() {
767
+ this.conversionDocumentGeneration += 1;
663
768
  try {
664
769
  this.unsub?.();
665
770
  }
666
771
  catch {
667
772
  // Ignore subscription cleanup errors
668
773
  }
774
+ this.resetPreviewState();
775
+ this.replaceAssetSession(null);
776
+ this.teardownEntryRenderer();
777
+ // Release any whenRendered() waiters so their promises do not hang.
778
+ this.flushRenderedWaiters();
669
779
  try {
670
780
  this.cmEditor?.destroy();
671
781
  }
@@ -679,6 +789,550 @@ export class MdzipWorkspaceView {
679
789
  // Ignore DOM cleanup errors
680
790
  }
681
791
  }
792
+ /**
793
+ * Replaces the rendering configuration without recreating the view (and
794
+ * therefore without re-opening the workspace). Wrappers call this when
795
+ * renderer props change; the cost is one preview re-render and entry
796
+ * renderer re-match.
797
+ */
798
+ setRenderingOptions(options) {
799
+ if ('markdownRenderer' in options) {
800
+ this.markdownRenderer = options.markdownRenderer ?? undefined;
801
+ }
802
+ if (options.markdownExtensions) {
803
+ this.markdownExtensions = options.markdownExtensions;
804
+ }
805
+ if (options.entryRenderers) {
806
+ this.entryRenderers = options.entryRenderers;
807
+ }
808
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
809
+ this.resetPreviewState();
810
+ this.teardownEntryRenderer();
811
+ this.entryMatchMissKey = null;
812
+ this.render();
813
+ }
814
+ /**
815
+ * Tears down all custom rendering state: aborts in-flight renders, destroys
816
+ * mounted extension and entry renderer handles, and clears memo caches.
817
+ * Used when the workspace is replaced or the view is destroyed.
818
+ */
819
+ resetRenderingState() {
820
+ this.resetPreviewState();
821
+ this.teardownEntryRenderer();
822
+ this.entryMatchMissKey = null;
823
+ }
824
+ replaceAssetSession(session) {
825
+ this.assetSession?.destroy();
826
+ this.assetSession = session;
827
+ this.resetPreviewState();
828
+ }
829
+ resetPreviewState() {
830
+ this.previewAbort?.abort();
831
+ this.previewAbort = null;
832
+ this.previewGeneration += 1;
833
+ this.destroyPreviewHandles();
834
+ this.previewMemo = null;
835
+ }
836
+ destroyPreviewHandles() {
837
+ const handles = this.previewHandles;
838
+ this.previewHandles = [];
839
+ for (const handle of handles) {
840
+ try {
841
+ handle.destroy();
842
+ }
843
+ catch (error) {
844
+ this.options.onFailed?.(error);
845
+ }
846
+ }
847
+ }
848
+ updatePreview(snapshot, entryClaimed) {
849
+ if (entryClaimed) {
850
+ // The entry renderer owns the pane stack; release preview resources so
851
+ // a later fallback re-renders from scratch.
852
+ if (this.previewMemo || this.previewHandles.length > 0 || this.previewAbort) {
853
+ this.resetPreviewState();
854
+ this.elPreviewContent.replaceChildren();
855
+ }
856
+ // There is no built-in preview to wait for; release any waiters.
857
+ this.previewHydrated = true;
858
+ this.flushRenderedWaiters();
859
+ return;
860
+ }
861
+ const memo = this.previewMemo;
862
+ if (memo
863
+ && memo.path === snapshot.currentPath
864
+ && memo.pathType === snapshot.currentPathType
865
+ && memo.text === snapshot.currentText
866
+ && memo.colorScheme === this.colorScheme) {
867
+ // Nothing that feeds the preview changed; keep the existing DOM and any
868
+ // mounted extension handles.
869
+ return;
870
+ }
871
+ this.previewAbort?.abort();
872
+ this.previewAbort = null;
873
+ this.destroyPreviewHandles();
874
+ const generation = ++this.previewGeneration;
875
+ this.previewHydrated = false;
876
+ this.previewMemo = {
877
+ path: snapshot.currentPath,
878
+ pathType: snapshot.currentPathType,
879
+ text: snapshot.currentText,
880
+ colorScheme: this.colorScheme
881
+ };
882
+ if (snapshot.currentPathType !== 'markdown') {
883
+ if (snapshot.currentPathType === 'image' && this.assetSession) {
884
+ const abort = new AbortController();
885
+ this.previewAbort = abort;
886
+ void this.assetSession.resolve(snapshot.currentPath, snapshot.currentPath).then((src) => {
887
+ if (generation !== this.previewGeneration || abort.signal.aborted)
888
+ return;
889
+ this.elPreviewContent.innerHTML = src
890
+ ? `<div class="asset-preview-wrap"><img class="asset-preview-image" src="${escapeHtml(src)}" alt="${escapeHtml(snapshot.currentPath)}"></div>`
891
+ : renderMdzipPreviewHtml(snapshot);
892
+ this.firePreviewRendered(snapshot, generation);
893
+ this.fireAssetsHydrated(snapshot, generation);
894
+ }).catch((error) => this.options.onFailed?.(error));
895
+ }
896
+ else {
897
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
898
+ this.firePreviewRendered(snapshot, generation);
899
+ this.fireAssetsHydrated(snapshot, generation);
900
+ }
901
+ return;
902
+ }
903
+ const abort = new AbortController();
904
+ this.previewAbort = abort;
905
+ const context = this.createMarkdownContext(snapshot, abort.signal);
906
+ let result;
907
+ try {
908
+ result = this.renderingService.renderMarkdown(snapshot.currentText, context);
909
+ }
910
+ catch (error) {
911
+ this.options.onFailed?.(error);
912
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
913
+ this.firePreviewRendered(snapshot, generation);
914
+ this.fireAssetsHydrated(snapshot, generation);
915
+ return;
916
+ }
917
+ if (typeof result === 'string') {
918
+ // Sync fast path: no microtask hop when every pipeline stage is sync.
919
+ this.applyPreviewHtml(result, snapshot, context, generation);
920
+ return;
921
+ }
922
+ void result.then((html) => {
923
+ if (generation !== this.previewGeneration || abort.signal.aborted) {
924
+ return; // Stale: the selection or content moved on while rendering.
925
+ }
926
+ this.applyPreviewHtml(html, snapshot, context, generation);
927
+ }).catch((error) => {
928
+ if (generation !== this.previewGeneration || abort.signal.aborted) {
929
+ return;
930
+ }
931
+ if (error?.name !== 'AbortError') {
932
+ this.options.onFailed?.(error);
933
+ }
934
+ });
935
+ }
936
+ applyPreviewHtml(html, snapshot, context, generation) {
937
+ // When the preview references archive images, mount the text immediately
938
+ // and hydrate each image progressively (reserving layout space from its
939
+ // sniffed intrinsic size), rather than blocking the whole preview on image
940
+ // resolution. Other markdown mounts synchronously.
941
+ if (this.assetSession && /<img\b/i.test(html)) {
942
+ this.mountProgressivePreview(html, snapshot, context, generation);
943
+ return;
944
+ }
945
+ this.mountPreviewHtml(html, snapshot, context, generation);
946
+ }
947
+ mountPreviewHtml(html, snapshot, context, generation) {
948
+ this.elPreviewContent.innerHTML = html;
949
+ this.mountPreviewExtensions(context, generation);
950
+ // No archive images to resolve on this path: the preview is ready now.
951
+ this.firePreviewRendered(snapshot, generation);
952
+ this.fireAssetsHydrated(snapshot, generation);
953
+ }
954
+ /**
955
+ * Mounts the rendered text immediately with image placeholders, then swaps
956
+ * each archive image in as it resolves. `onPreviewRendered` fires once the
957
+ * text is in the DOM; `onAssetsHydrated` fires once every referenced image
958
+ * has resolved and had its final `src` assigned (or there are none).
959
+ */
960
+ mountProgressivePreview(html, snapshot, context, generation) {
961
+ this.elPreviewContent.innerHTML = html;
962
+ const session = this.assetSession;
963
+ const document = this.elPreviewContent.ownerDocument;
964
+ const pending = [];
965
+ for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
966
+ const source = image.getAttribute('src');
967
+ // Leave external, protocol-relative, data, and fragment URLs untouched.
968
+ if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
969
+ continue;
970
+ }
971
+ // Drop the archive-relative src so the browser does not fetch the bad
972
+ // path. Wrap the image in a collapsed slot so the text stays compact and
973
+ // immediately readable; the slot eases open to the reserved height once
974
+ // the image resolves.
975
+ image.removeAttribute('src');
976
+ image.classList.add('mdzip-image-loading');
977
+ const slot = document.createElement('span');
978
+ slot.className = 'mdzip-image-slot';
979
+ image.parentNode?.insertBefore(slot, image);
980
+ slot.appendChild(image);
981
+ pending.push({ image, slot, source });
982
+ }
983
+ this.mountPreviewExtensions(context, generation);
984
+ this.firePreviewRendered(snapshot, generation);
985
+ if (!session || pending.length === 0) {
986
+ this.fireAssetsHydrated(snapshot, generation);
987
+ return;
988
+ }
989
+ let remaining = pending.length;
990
+ const settle = () => {
991
+ remaining -= 1;
992
+ if (remaining === 0) {
993
+ this.fireAssetsHydrated(snapshot, generation);
994
+ }
995
+ };
996
+ for (const { image, slot, source } of pending) {
997
+ void session.resolveImage(source, context.currentPath).then((resolved) => {
998
+ if (generation !== this.previewGeneration || context.signal.aborted) {
999
+ settle();
1000
+ return;
1001
+ }
1002
+ if (!resolved) {
1003
+ image.classList.remove('mdzip-image-loading');
1004
+ this.openImageSlot(slot);
1005
+ settle();
1006
+ return;
1007
+ }
1008
+ // Size the reserved box from the sniffed dimensions so the slot eases
1009
+ // open to the image's exact height in a single slide — and the pixels
1010
+ // drop into an already-correct box with no further reflow.
1011
+ if (resolved.width && resolved.height) {
1012
+ image.setAttribute('width', String(resolved.width));
1013
+ image.setAttribute('height', String(resolved.height));
1014
+ }
1015
+ this.attachImageLoadHandlers(image, source, resolved.url, context, generation);
1016
+ image.setAttribute('src', resolved.url);
1017
+ this.openImageSlot(slot);
1018
+ settle();
1019
+ }).catch((error) => {
1020
+ if (error?.name !== 'AbortError') {
1021
+ this.options.onFailed?.(error);
1022
+ }
1023
+ image.classList.remove('mdzip-image-loading');
1024
+ this.openImageSlot(slot);
1025
+ settle();
1026
+ });
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Reveals a collapsed image slot. Flushing layout before toggling the class
1031
+ * lets the `0fr -> 1fr` grid transition run from the collapsed state instead
1032
+ * of being coalesced into the initial mount; CSS snaps it open instantly
1033
+ * under `prefers-reduced-motion`.
1034
+ */
1035
+ openImageSlot(slot) {
1036
+ void slot.offsetHeight;
1037
+ slot.classList.add('mdzip-image-open');
1038
+ }
1039
+ /**
1040
+ * Wires `<img>` load/error handling for a resolved archive image.
1041
+ *
1042
+ * `resolveImage()` already succeeded, so an `error` here is environmental
1043
+ * rather than a missing asset — most often a host whose CSP `img-src` blocks
1044
+ * the `blob:` object URL. When that happens we retry once with a `data:` URL
1045
+ * (which many such hosts still permit), and only if that also fails do we
1046
+ * surface the failure via `onFailed` instead of leaving a silent blank box.
1047
+ */
1048
+ attachImageLoadHandlers(image, source, resolvedUrl, context, generation) {
1049
+ const clear = () => image.classList.remove('mdzip-image-loading');
1050
+ image.addEventListener('load', clear, { once: true });
1051
+ image.addEventListener('error', () => {
1052
+ const session = this.assetSession;
1053
+ if (resolvedUrl.startsWith('blob:') && session) {
1054
+ void session.resolveDataUrl(source, context.currentPath).then((dataUrl) => {
1055
+ if (generation !== this.previewGeneration || context.signal.aborted) {
1056
+ clear();
1057
+ return;
1058
+ }
1059
+ if (dataUrl && dataUrl !== resolvedUrl) {
1060
+ image.addEventListener('load', clear, { once: true });
1061
+ image.addEventListener('error', () => {
1062
+ clear();
1063
+ this.reportImageLoadFailure(source);
1064
+ }, { once: true });
1065
+ image.setAttribute('src', dataUrl);
1066
+ return;
1067
+ }
1068
+ clear();
1069
+ this.reportImageLoadFailure(source);
1070
+ }).catch((error) => {
1071
+ clear();
1072
+ this.options.onFailed?.(error);
1073
+ });
1074
+ return;
1075
+ }
1076
+ clear();
1077
+ this.reportImageLoadFailure(source);
1078
+ }, { once: true });
1079
+ }
1080
+ reportImageLoadFailure(source) {
1081
+ this.options.onFailed?.(new Error(`Failed to load archive image "${source}". When embedding the editor in a `
1082
+ + 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
1083
+ + 'blob: and data:.'));
1084
+ }
1085
+ mountPreviewExtensions(context, generation) {
1086
+ for (const extension of this.markdownExtensions) {
1087
+ if (!extension.mount) {
1088
+ continue;
1089
+ }
1090
+ try {
1091
+ const mounted = extension.mount(this.elPreviewContent, context);
1092
+ if (isThenable(mounted)) {
1093
+ void Promise.resolve(mounted).then((handle) => {
1094
+ if (!handle) {
1095
+ return;
1096
+ }
1097
+ if (generation !== this.previewGeneration) {
1098
+ try {
1099
+ handle.destroy();
1100
+ }
1101
+ catch {
1102
+ // Stale handle cleanup failure is not actionable.
1103
+ }
1104
+ return;
1105
+ }
1106
+ this.previewHandles.push(handle);
1107
+ }).catch((error) => {
1108
+ if (generation === this.previewGeneration) {
1109
+ this.options.onFailed?.(error);
1110
+ }
1111
+ });
1112
+ }
1113
+ else if (mounted) {
1114
+ this.previewHandles.push(mounted);
1115
+ }
1116
+ }
1117
+ catch (error) {
1118
+ this.options.onFailed?.(error);
1119
+ }
1120
+ }
1121
+ }
1122
+ firePreviewRendered(snapshot, generation) {
1123
+ if (generation !== this.previewGeneration) {
1124
+ return;
1125
+ }
1126
+ try {
1127
+ this.options.onPreviewRendered?.(snapshot);
1128
+ }
1129
+ catch (error) {
1130
+ this.options.onFailed?.(error);
1131
+ }
1132
+ }
1133
+ fireAssetsHydrated(snapshot, generation) {
1134
+ if (generation !== this.previewGeneration) {
1135
+ return;
1136
+ }
1137
+ this.previewHydrated = true;
1138
+ try {
1139
+ this.options.onAssetsHydrated?.(snapshot);
1140
+ }
1141
+ catch (error) {
1142
+ this.options.onFailed?.(error);
1143
+ }
1144
+ this.flushRenderedWaiters();
1145
+ }
1146
+ flushRenderedWaiters() {
1147
+ const waiters = this.renderedWaiters;
1148
+ this.renderedWaiters = [];
1149
+ for (const resolve of waiters) {
1150
+ resolve();
1151
+ }
1152
+ }
1153
+ /**
1154
+ * Resolves once the current preview (including any images) is mounted and
1155
+ * hydrated. Resolves immediately when the latest preview is already ready.
1156
+ * Useful for revealing or animating content without observing private DOM.
1157
+ */
1158
+ whenRendered() {
1159
+ if (this.previewHydrated) {
1160
+ return Promise.resolve();
1161
+ }
1162
+ return new Promise((resolve) => this.renderedWaiters.push(resolve));
1163
+ }
1164
+ createMarkdownContext(snapshot, signal) {
1165
+ return {
1166
+ currentPath: snapshot.currentPath,
1167
+ sourceFormat: snapshot.sourceFormat,
1168
+ colorScheme: this.colorScheme,
1169
+ mode: snapshot.mode,
1170
+ manifest: snapshot.content.manifest,
1171
+ assetResolver: {
1172
+ resolveAssetUrl: (path) => this.assetSession?.resolveKnown(path, snapshot.currentPath)
1173
+ },
1174
+ signal
1175
+ };
1176
+ }
1177
+ /**
1178
+ * Entry renderer lifecycle. Renders are keyed by
1179
+ * (path, pathType, mode, sourceFormat): same key keeps the mounted handle
1180
+ * (calling `update()` when colorScheme or manifest changed), a changed key
1181
+ * destroys it and re-runs matching. Rename/move/delete of the backing entry
1182
+ * changes the path and is therefore handled as a selection change.
1183
+ */
1184
+ syncEntryRenderer(snapshot) {
1185
+ if (this.entryRenderers.length === 0) {
1186
+ return false;
1187
+ }
1188
+ const matchKey = [
1189
+ snapshot.currentPath,
1190
+ snapshot.currentPathType,
1191
+ snapshot.mode,
1192
+ snapshot.sourceFormat
1193
+ ].join('\u0000');
1194
+ if (this.entryState?.key === matchKey) {
1195
+ this.maybeUpdateEntryRenderer(snapshot);
1196
+ return true;
1197
+ }
1198
+ if (this.entryState) {
1199
+ this.teardownEntryRenderer();
1200
+ }
1201
+ if (this.entryMatchMissKey === matchKey) {
1202
+ return false;
1203
+ }
1204
+ const abort = new AbortController();
1205
+ const context = this.createEntryContext(snapshot, abort.signal);
1206
+ const renderer = this.matchEntryRenderer(context);
1207
+ if (!renderer) {
1208
+ this.entryMatchMissKey = matchKey;
1209
+ return false;
1210
+ }
1211
+ this.entryMatchMissKey = null;
1212
+ const generation = ++this.entryGeneration;
1213
+ const state = {
1214
+ key: matchKey,
1215
+ renderer,
1216
+ handle: null,
1217
+ abort,
1218
+ lastColorScheme: this.colorScheme,
1219
+ lastManifest: snapshot.content.manifest
1220
+ };
1221
+ this.entryState = state;
1222
+ this.elEntryPane.replaceChildren();
1223
+ try {
1224
+ const mounted = renderer.mount(this.elEntryPane, context);
1225
+ if (isThenable(mounted)) {
1226
+ void Promise.resolve(mounted).then((handle) => {
1227
+ if (this.entryGeneration !== generation || this.entryState !== state) {
1228
+ // Stale mount: the selection moved on while mounting.
1229
+ try {
1230
+ handle?.destroy();
1231
+ }
1232
+ catch {
1233
+ // Stale handle cleanup failure is not actionable.
1234
+ }
1235
+ return;
1236
+ }
1237
+ state.handle = handle ?? null;
1238
+ }).catch((error) => {
1239
+ if (this.entryGeneration === generation) {
1240
+ this.options.onFailed?.(error);
1241
+ }
1242
+ });
1243
+ }
1244
+ else {
1245
+ state.handle = mounted ?? null;
1246
+ }
1247
+ }
1248
+ catch (error) {
1249
+ this.options.onFailed?.(error);
1250
+ }
1251
+ return true;
1252
+ }
1253
+ maybeUpdateEntryRenderer(snapshot) {
1254
+ const state = this.entryState;
1255
+ if (!state) {
1256
+ return;
1257
+ }
1258
+ const manifest = snapshot.content.manifest;
1259
+ if (state.lastColorScheme === this.colorScheme && state.lastManifest === manifest) {
1260
+ return;
1261
+ }
1262
+ state.lastColorScheme = this.colorScheme;
1263
+ state.lastManifest = manifest;
1264
+ if (!state.handle?.update) {
1265
+ return;
1266
+ }
1267
+ const context = this.createEntryContext(snapshot, state.abort.signal);
1268
+ try {
1269
+ const result = state.handle.update(context);
1270
+ if (isThenable(result)) {
1271
+ void Promise.resolve(result).catch((error) => this.options.onFailed?.(error));
1272
+ }
1273
+ }
1274
+ catch (error) {
1275
+ this.options.onFailed?.(error);
1276
+ }
1277
+ }
1278
+ matchEntryRenderer(context) {
1279
+ const byPriority = [...this.entryRenderers]
1280
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
1281
+ for (const renderer of byPriority) {
1282
+ try {
1283
+ if (renderer.matches(context)) {
1284
+ return renderer;
1285
+ }
1286
+ }
1287
+ catch (error) {
1288
+ this.options.onFailed?.(error);
1289
+ }
1290
+ }
1291
+ return null;
1292
+ }
1293
+ teardownEntryRenderer() {
1294
+ const active = this.entryState;
1295
+ if (!active) {
1296
+ return;
1297
+ }
1298
+ this.entryState = null;
1299
+ this.entryGeneration += 1;
1300
+ active.abort.abort();
1301
+ try {
1302
+ active.handle?.destroy();
1303
+ }
1304
+ catch (error) {
1305
+ this.options.onFailed?.(error);
1306
+ }
1307
+ this.elEntryPane.replaceChildren();
1308
+ }
1309
+ createEntryContext(snapshot, signal) {
1310
+ const workspace = this.workspace;
1311
+ const path = snapshot.currentPath;
1312
+ return {
1313
+ path,
1314
+ pathType: snapshot.currentPathType,
1315
+ mode: snapshot.mode,
1316
+ sourceFormat: snapshot.sourceFormat,
1317
+ colorScheme: this.colorScheme,
1318
+ manifest: snapshot.content.manifest,
1319
+ snapshot,
1320
+ signal,
1321
+ readBytes: async () => {
1322
+ const bytes = await workspace?.readPathBytes(path);
1323
+ if (!bytes) {
1324
+ throw new Error(`Cannot read bytes for "${path}".`);
1325
+ }
1326
+ return bytes;
1327
+ },
1328
+ updateManifest: async (manifest) => {
1329
+ if (!workspace) {
1330
+ throw new Error('Workspace is not open.');
1331
+ }
1332
+ await workspace.updateManifest(manifest);
1333
+ }
1334
+ };
1335
+ }
682
1336
  createCmEditor(parent, initialText, mode) {
683
1337
  // eslint-disable-next-line @typescript-eslint/no-this-alias
684
1338
  const self = this;
@@ -754,6 +1408,15 @@ export class MdzipWorkspaceView {
754
1408
  }
755
1409
  return editor;
756
1410
  }
1411
+ async ensureCmEditor(force = false) {
1412
+ const snapshot = this.workspace?.snapshot();
1413
+ if (this.cmEditor || !snapshot || (!force && this.layout === 'preview')
1414
+ || !canShowSourceLayout(snapshot)) {
1415
+ return this.cmEditor;
1416
+ }
1417
+ this.cmEditor = this.createCmEditor(this.elEditorHost, snapshot.currentText, snapshot.mode);
1418
+ return this.cmEditor;
1419
+ }
757
1420
  render() {
758
1421
  const snapshot = this.workspace?.snapshot() ?? null;
759
1422
  this.elEmptyState.hidden = snapshot !== null;
@@ -764,7 +1427,9 @@ export class MdzipWorkspaceView {
764
1427
  return;
765
1428
  }
766
1429
  this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
767
- const canEdit = canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, snapshot.mode);
1430
+ const entryClaimed = this.syncEntryRenderer(snapshot);
1431
+ const canEdit = !entryClaimed
1432
+ && canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, snapshot.mode);
768
1433
  const canShowSource = canShowSourceLayout(snapshot);
769
1434
  const showNavigationControl = this.controlPolicy.navigation;
770
1435
  const showTitleControl = this.controlPolicy.title.visible;
@@ -877,14 +1542,16 @@ export class MdzipWorkspaceView {
877
1542
  effects: this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(snapshot.mode === 'read-only'))
878
1543
  });
879
1544
  }
880
- this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
1545
+ this.updatePreview(snapshot, entryClaimed);
881
1546
  const pt = snapshot.currentPathType;
882
- const showEdit = (pt === 'markdown' || pt === 'text') && this.layout !== 'preview';
883
- const showPreview = pt === 'image' || pt === 'binary' || pt === 'text'
884
- || (pt === 'markdown' && this.layout !== 'source');
1547
+ const showEdit = !entryClaimed && (pt === 'markdown' || pt === 'text') && this.layout !== 'preview';
1548
+ const showPreview = !entryClaimed && (pt === 'image' || pt === 'binary' || pt === 'text'
1549
+ || (pt === 'markdown' && this.layout !== 'source'));
885
1550
  this.elEditPane.classList.toggle('active', showEdit);
886
1551
  this.elPreviewPane.classList.toggle('active', showPreview);
887
- this.elPaneStack.classList.toggle('split-mode', this.layout === 'split');
1552
+ this.elEntryPane.classList.toggle('active', entryClaimed);
1553
+ this.elPaneStack.classList.toggle('split-mode', this.layout === 'split' && !entryClaimed);
1554
+ this.elPaneStack.classList.toggle('entry-claimed', entryClaimed);
888
1555
  if (!showTitleControl) {
889
1556
  this.titleDialogOpen = false;
890
1557
  this.metadataDialogOpen = false;
@@ -1026,17 +1693,17 @@ export class MdzipWorkspaceView {
1026
1693
  });
1027
1694
  this.elPreviewBtn.addEventListener('click', () => {
1028
1695
  if (this.controlPolicy.layout.preview) {
1029
- this.setLayout('preview');
1696
+ void this.setLayout('preview');
1030
1697
  }
1031
1698
  });
1032
1699
  this.elSplitBtn.addEventListener('click', () => {
1033
1700
  if (this.controlPolicy.layout.split) {
1034
- this.setLayout('split');
1701
+ void this.setLayout('split');
1035
1702
  }
1036
1703
  });
1037
1704
  this.elSourceBtn.addEventListener('click', () => {
1038
1705
  if (this.controlPolicy.layout.source) {
1039
- this.setLayout('source');
1706
+ void this.setLayout('source');
1040
1707
  }
1041
1708
  });
1042
1709
  this.elSaveBtn.addEventListener('click', () => {
@@ -1477,16 +2144,58 @@ export class MdzipWorkspaceView {
1477
2144
  ['Entry point', snapshot.sourceFormat === 'mdz' ? snapshot.content.entryPoint : 'Not applicable']
1478
2145
  ];
1479
2146
  this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
1480
- const row = this.elRoot.ownerDocument.createElement('div');
1481
- row.className = 'metadata-row';
1482
- const term = this.elRoot.ownerDocument.createElement('dt');
1483
- term.textContent = label;
1484
- const detail = this.elRoot.ownerDocument.createElement('dd');
1485
- detail.textContent = value;
1486
- row.append(term, detail);
1487
- return row;
2147
+ return this.createMetadataRow(label, value);
2148
+ }));
2149
+ const libraries = [
2150
+ ...(this.options.libraries ?? []),
2151
+ ...MDZIP_RUNTIME_LIBRARIES
2152
+ ].filter((library, index, all) => all.findIndex((candidate) => candidate.name === library.name) === index);
2153
+ this.elLibraryList.replaceChildren(...libraries.map((library) => {
2154
+ return this.createLibraryRow(library);
1488
2155
  }));
1489
2156
  }
2157
+ createMetadataRow(label, value) {
2158
+ const row = this.elRoot.ownerDocument.createElement('div');
2159
+ row.className = 'metadata-row';
2160
+ const term = this.elRoot.ownerDocument.createElement('dt');
2161
+ term.textContent = label;
2162
+ const detail = this.elRoot.ownerDocument.createElement('dd');
2163
+ detail.textContent = value;
2164
+ row.append(term, detail);
2165
+ return row;
2166
+ }
2167
+ createLibraryRow(library) {
2168
+ const row = this.elRoot.ownerDocument.createElement('div');
2169
+ row.className = 'metadata-row library-row';
2170
+ const term = this.elRoot.ownerDocument.createElement('dt');
2171
+ const name = this.elRoot.ownerDocument.createElement('span');
2172
+ name.className = 'library-name';
2173
+ if (library.repositoryUrl) {
2174
+ const link = this.elRoot.ownerDocument.createElement('a');
2175
+ link.href = library.repositoryUrl;
2176
+ link.target = '_blank';
2177
+ link.rel = 'noopener noreferrer';
2178
+ link.textContent = library.name;
2179
+ link.setAttribute('aria-label', `${library.name} repository`);
2180
+ name.append(link);
2181
+ }
2182
+ else {
2183
+ name.textContent = library.name;
2184
+ }
2185
+ const version = this.elRoot.ownerDocument.createElement('span');
2186
+ version.className = 'library-version';
2187
+ version.textContent = library.version;
2188
+ term.append(name, version);
2189
+ const detail = this.elRoot.ownerDocument.createElement('dd');
2190
+ if (library.description) {
2191
+ const description = this.elRoot.ownerDocument.createElement('span');
2192
+ description.className = 'library-description';
2193
+ description.textContent = library.description;
2194
+ detail.append(description);
2195
+ }
2196
+ row.append(term, detail);
2197
+ return row;
2198
+ }
1490
2199
  requestMdzConversion(action) {
1491
2200
  const snapshot = this.workspace?.snapshot();
1492
2201
  if (!snapshot || snapshot.mode === 'read-only' || snapshot.sourceFormat !== 'markdown') {
@@ -1501,8 +2210,9 @@ export class MdzipWorkspaceView {
1501
2210
  return;
1502
2211
  }
1503
2212
  this.conversionHookPending = true;
2213
+ const context = this.createConversionContext(action);
1504
2214
  void Promise.resolve()
1505
- .then(() => hook(action))
2215
+ .then(() => hook(action, context))
1506
2216
  .then((handled) => {
1507
2217
  this.conversionHookPending = false;
1508
2218
  if (!handled) {
@@ -1515,6 +2225,75 @@ export class MdzipWorkspaceView {
1515
2225
  this.openConversionDialog(action);
1516
2226
  });
1517
2227
  }
2228
+ createConversionContext(action) {
2229
+ const workspace = this.workspace;
2230
+ const snapshot = workspace?.snapshot();
2231
+ const selection = this.cmEditor?.state.selection.main;
2232
+ const captured = workspace && snapshot ? {
2233
+ workspace,
2234
+ documentGeneration: this.conversionDocumentGeneration,
2235
+ path: snapshot.currentPath,
2236
+ text: snapshot.currentText,
2237
+ selectionStart: selection?.from,
2238
+ selectionEnd: selection?.to
2239
+ } : null;
2240
+ let consumed = false;
2241
+ const take = () => {
2242
+ if (consumed || !captured || this.workspace !== captured.workspace
2243
+ || this.conversionDocumentGeneration !== captured.documentGeneration) {
2244
+ return null;
2245
+ }
2246
+ const current = captured.workspace.snapshot();
2247
+ if (current.mode === 'read-only'
2248
+ || current.sourceFormat !== 'markdown'
2249
+ || current.currentPath !== captured.path
2250
+ || current.currentText !== captured.text) {
2251
+ return null;
2252
+ }
2253
+ consumed = true;
2254
+ return captured;
2255
+ };
2256
+ return {
2257
+ insertMarkdown: async (text) => {
2258
+ const target = take();
2259
+ if (!target || target.selectionStart === undefined || target.selectionEnd === undefined) {
2260
+ return false;
2261
+ }
2262
+ const nextText = target.text.slice(0, target.selectionStart)
2263
+ + text
2264
+ + target.text.slice(target.selectionEnd);
2265
+ target.workspace.editText(nextText);
2266
+ this.render();
2267
+ const editor = await this.ensureCmEditor();
2268
+ if (editor) {
2269
+ editor.dispatch({ selection: { anchor: target.selectionStart + text.length } });
2270
+ editor.focus();
2271
+ }
2272
+ return true;
2273
+ },
2274
+ convertToMdz: async () => {
2275
+ const target = take();
2276
+ if (!target || !await this.convertToMdz()) {
2277
+ return false;
2278
+ }
2279
+ if (action.kind === 'navigation') {
2280
+ this.navVisible = true;
2281
+ this.render();
2282
+ return true;
2283
+ }
2284
+ const editor = await this.ensureCmEditor();
2285
+ if (editor && target.selectionStart !== undefined) {
2286
+ editor.dispatch({ selection: { anchor: target.selectionStart } });
2287
+ }
2288
+ if (action.kind === 'image-file') {
2289
+ await this.insertImageFile(action.file);
2290
+ return true;
2291
+ }
2292
+ this.elImageInput.click();
2293
+ return true;
2294
+ }
2295
+ };
2296
+ }
1518
2297
  openConversionDialog(action) {
1519
2298
  this.conversionAction = action;
1520
2299
  this.render();
@@ -1857,9 +2636,10 @@ export class MdzipWorkspaceView {
1857
2636
  this.setColorScheme(colorScheme);
1858
2637
  this.options.onColorSchemeChanged?.(colorScheme);
1859
2638
  }
1860
- setLayout(requested) {
2639
+ async setLayout(requested) {
1861
2640
  const snapshot = this.workspace?.snapshot();
1862
2641
  this.layout = snapshot ? this.validLayoutForSnapshot(requested, snapshot) : requested;
2642
+ await this.ensureCmEditor();
1863
2643
  this.render();
1864
2644
  }
1865
2645
  navMenuTargetFromElement(element) {
@@ -2609,6 +3389,7 @@ const SHELL_HTML = `
2609
3389
  <section class="pane preview-pane" data-ref="preview-pane">
2610
3390
  <article class="preview-content" data-ref="preview-content"></article>
2611
3391
  </section>
3392
+ <section class="pane entry-pane" data-ref="entry-pane"></section>
2612
3393
  </div>
2613
3394
  </div>
2614
3395
 
@@ -2649,6 +3430,8 @@ const SHELL_HTML = `
2649
3430
  <div class="title-dialog metadata-dialog">
2650
3431
  <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
2651
3432
  <dl data-ref="metadata-list"></dl>
3433
+ <h4>Libraries</h4>
3434
+ <dl data-ref="library-list"></dl>
2652
3435
  <div class="title-dialog-actions">
2653
3436
  <button type="button" class="save-title" data-action="close-metadata">Close</button>
2654
3437
  </div>