@mdzip/editor 1.3.0 → 1.3.8

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 (48) hide show
  1. package/README.md +145 -8
  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 +81 -0
  6. package/dist/asset-cache.d.ts.map +1 -0
  7. package/dist/asset-cache.js +365 -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/preview.d.ts +10 -0
  30. package/dist/preview.d.ts.map +1 -0
  31. package/dist/preview.js +9 -0
  32. package/dist/preview.js.map +1 -0
  33. package/dist/rendering.d.ts +138 -2
  34. package/dist/rendering.d.ts.map +1 -1
  35. package/dist/rendering.js +108 -13
  36. package/dist/rendering.js.map +1 -1
  37. package/dist/view-css.d.ts.map +1 -1
  38. package/dist/view-css.js +109 -1
  39. package/dist/view-css.js.map +1 -1
  40. package/dist/view.d.ts +136 -1
  41. package/dist/view.d.ts.map +1 -1
  42. package/dist/view.js +733 -22
  43. package/dist/view.js.map +1 -1
  44. package/dist/workspace.d.ts +15 -0
  45. package/dist/workspace.d.ts.map +1 -1
  46. package/dist/workspace.js +28 -0
  47. package/dist/workspace.js.map +1 -1
  48. package/package.json +23 -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,7 @@ export class MdzipWorkspaceView {
414
421
  this.pendingNewFolders = new Set();
415
422
  this.pendingReplacePath = null;
416
423
  this.conversionHookPending = false;
424
+ this.conversionDocumentGeneration = 0;
417
425
  this.dragSourcePath = null;
418
426
  this.dragOverElement = null;
419
427
  this.tooltipState = null;
@@ -423,6 +431,25 @@ export class MdzipWorkspaceView {
423
431
  this.readOnlyCompartment = new Compartment();
424
432
  this.updatingCm = false;
425
433
  this.syncing = false;
434
+ this.markdownExtensions = [];
435
+ this.entryRenderers = [];
436
+ this.renderingService = new MdzipRenderingService();
437
+ // Preview render memo: the preview pipeline only re-runs when one of these
438
+ // inputs actually changed, so unrelated snapshot renders (dialogs, nav,
439
+ // layout toggles) never reset preview DOM or re-run extension mounts.
440
+ this.previewMemo = null;
441
+ this.previewGeneration = 0;
442
+ this.previewAbort = null;
443
+ this.previewHandles = [];
444
+ // Whether the latest preview generation has finished mounting and hydrating
445
+ // its images; drives `whenRendered()` and gates the hydration callback.
446
+ this.previewHydrated = false;
447
+ this.renderedWaiters = [];
448
+ this.entryState = null;
449
+ // Negative match cache: with only non-matching renderers registered,
450
+ // matches() does not re-run on every snapshot render of the same entry.
451
+ this.entryMatchMissKey = null;
452
+ this.entryGeneration = 0;
426
453
  this.options = options;
427
454
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
428
455
  this.navigationMode = options.navigationMode ?? 'editor';
@@ -432,6 +459,10 @@ export class MdzipWorkspaceView {
432
459
  ? 'dark'
433
460
  : 'light');
434
461
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
462
+ this.markdownRenderer = options.markdownRenderer;
463
+ this.markdownExtensions = options.markdownExtensions ?? [];
464
+ this.entryRenderers = options.entryRenderers ?? [];
465
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
435
466
  injectStyles(container.ownerDocument);
436
467
  container.replaceChildren();
437
468
  container.innerHTML = SHELL_HTML;
@@ -468,6 +499,7 @@ export class MdzipWorkspaceView {
468
499
  this.elSplitResizer = q('[data-ref="split-resizer"]');
469
500
  this.elPreviewPane = q('[data-ref="preview-pane"]');
470
501
  this.elPreviewContent = q('[data-ref="preview-content"]');
502
+ this.elEntryPane = q('[data-ref="entry-pane"]');
471
503
  this.elTitleDialog = q('[data-ref="title-dialog"]');
472
504
  this.elTitleInput = q('[data-ref="title-input"]');
473
505
  this.elTitleValidation = q('[data-ref="title-validation"]');
@@ -475,6 +507,7 @@ export class MdzipWorkspaceView {
475
507
  this.elTitleResetBtn = q('[data-ref="title-reset-btn"]');
476
508
  this.elMetadataDialog = q('[data-ref="metadata-dialog"]');
477
509
  this.elMetadataList = q('[data-ref="metadata-list"]');
510
+ this.elLibraryList = q('[data-ref="library-list"]');
478
511
  this.elConversionDialog = q('[data-ref="conversion-dialog"]');
479
512
  this.elConversionConfirmBtn = q('[data-ref="conversion-confirm-btn"]');
480
513
  this.elNavMenu = q('[data-ref="nav-menu"]');
@@ -505,19 +538,37 @@ export class MdzipWorkspaceView {
505
538
  }
506
539
  async openArchive(bytes, options = {}) {
507
540
  this.unsub?.();
541
+ this.resetRenderingState();
508
542
  this.cmEditor?.destroy();
509
543
  this.cmEditor = null;
510
544
  this.workspace = null;
545
+ this.replaceAssetSession(null);
546
+ this.conversionDocumentGeneration += 1;
511
547
  try {
512
548
  const ws = await MdzipWorkspaceService.open(bytes, options);
513
549
  this.workspace = ws;
550
+ this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
551
+ cache: this.options.assetCache,
552
+ sourceId: () => mdzipArchiveSourceId(bytes),
553
+ onFailed: this.options.onFailed
554
+ }));
514
555
  const snap = ws.snapshot();
515
556
  if (snap.sourceFormat === 'markdown') {
516
557
  this.navVisible = false;
517
558
  }
518
559
  this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
519
- this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
560
+ await this.ensureCmEditor();
520
561
  this.unsub = ws.subscribe((event) => {
562
+ if (event.changes.includes('asset')) {
563
+ this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
564
+ cache: this.options.assetCache,
565
+ sourceId: () => mdzipArchiveSourceId(event.snapshot.archiveBytes),
566
+ onFailed: this.options.onFailed
567
+ }));
568
+ }
569
+ if (event.changes.includes('workspace') || event.changes.includes('document')) {
570
+ this.conversionDocumentGeneration += 1;
571
+ }
521
572
  this.render();
522
573
  void this.notifyChanged(event);
523
574
  });
@@ -556,16 +607,37 @@ export class MdzipWorkspaceView {
556
607
  */
557
608
  async openWorkspace(workspace, options = {}) {
558
609
  this.unsub?.();
610
+ this.resetRenderingState();
559
611
  this.cmEditor?.destroy();
560
612
  this.cmEditor = null;
561
613
  this.workspace = null;
614
+ this.replaceAssetSession(null);
615
+ this.conversionDocumentGeneration += 1;
562
616
  try {
563
617
  const ws = await MdzipWorkspaceService.openWorkspace(workspace, options);
564
618
  this.workspace = ws;
619
+ this.replaceAssetSession(new MdzipAssetSession(ws, ws.snapshot().workspace.assets, this.elRoot.ownerDocument, {
620
+ cache: this.options.assetCache,
621
+ sourceId: options.assetSourceId
622
+ ?? (options.archiveBytes ? () => mdzipArchiveSourceId(options.archiveBytes) : undefined),
623
+ onFailed: this.options.onFailed
624
+ }));
565
625
  const snap = ws.snapshot();
566
626
  this.layout = this.validLayoutForSnapshot(this.options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy), snap);
567
- this.cmEditor = this.createCmEditor(this.elEditorHost, snap.currentText, snap.mode);
627
+ await this.ensureCmEditor();
568
628
  this.unsub = ws.subscribe((event) => {
629
+ if (event.changes.includes('asset')) {
630
+ this.replaceAssetSession(new MdzipAssetSession(ws, event.snapshot.workspace.assets, this.elRoot.ownerDocument, {
631
+ cache: this.options.assetCache,
632
+ sourceId: event.snapshot.archiveBytes.length
633
+ ? () => mdzipArchiveSourceId(event.snapshot.archiveBytes)
634
+ : undefined,
635
+ onFailed: this.options.onFailed
636
+ }));
637
+ }
638
+ if (event.changes.includes('workspace') || event.changes.includes('document')) {
639
+ this.conversionDocumentGeneration += 1;
640
+ }
569
641
  this.render();
570
642
  void this.notifyChanged(event);
571
643
  });
@@ -613,14 +685,18 @@ export class MdzipWorkspaceView {
613
685
  return this.workspace?.listAssets() ?? [];
614
686
  }
615
687
  canExecuteCommand(command) {
688
+ // Availability is currently uniform across commands; the parameter is
689
+ // kept so per-command policies stay a non-breaking change.
690
+ void command;
616
691
  const snapshot = this.workspace?.snapshot();
617
- if (!snapshot || !this.cmEditor || snapshot.mode === 'read-only'
692
+ if (!snapshot || snapshot.mode === 'read-only'
618
693
  || snapshot.currentPathType !== 'markdown') {
619
694
  return false;
620
695
  }
621
696
  return true;
622
697
  }
623
698
  async executeCommand(command, file) {
699
+ await this.ensureCmEditor(true);
624
700
  if (!this.canExecuteCommand(command)) {
625
701
  return false;
626
702
  }
@@ -660,12 +736,18 @@ export class MdzipWorkspaceView {
660
736
  this.cmEditor?.focus();
661
737
  }
662
738
  destroy() {
739
+ this.conversionDocumentGeneration += 1;
663
740
  try {
664
741
  this.unsub?.();
665
742
  }
666
743
  catch {
667
744
  // Ignore subscription cleanup errors
668
745
  }
746
+ this.resetPreviewState();
747
+ this.replaceAssetSession(null);
748
+ this.teardownEntryRenderer();
749
+ // Release any whenRendered() waiters so their promises do not hang.
750
+ this.flushRenderedWaiters();
669
751
  try {
670
752
  this.cmEditor?.destroy();
671
753
  }
@@ -679,6 +761,506 @@ export class MdzipWorkspaceView {
679
761
  // Ignore DOM cleanup errors
680
762
  }
681
763
  }
764
+ /**
765
+ * Replaces the rendering configuration without recreating the view (and
766
+ * therefore without re-opening the workspace). Wrappers call this when
767
+ * renderer props change; the cost is one preview re-render and entry
768
+ * renderer re-match.
769
+ */
770
+ setRenderingOptions(options) {
771
+ if ('markdownRenderer' in options) {
772
+ this.markdownRenderer = options.markdownRenderer ?? undefined;
773
+ }
774
+ if (options.markdownExtensions) {
775
+ this.markdownExtensions = options.markdownExtensions;
776
+ }
777
+ if (options.entryRenderers) {
778
+ this.entryRenderers = options.entryRenderers;
779
+ }
780
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
781
+ this.resetPreviewState();
782
+ this.teardownEntryRenderer();
783
+ this.entryMatchMissKey = null;
784
+ this.render();
785
+ }
786
+ /**
787
+ * Tears down all custom rendering state: aborts in-flight renders, destroys
788
+ * mounted extension and entry renderer handles, and clears memo caches.
789
+ * Used when the workspace is replaced or the view is destroyed.
790
+ */
791
+ resetRenderingState() {
792
+ this.resetPreviewState();
793
+ this.teardownEntryRenderer();
794
+ this.entryMatchMissKey = null;
795
+ }
796
+ replaceAssetSession(session) {
797
+ this.assetSession?.destroy();
798
+ this.assetSession = session;
799
+ this.resetPreviewState();
800
+ }
801
+ resetPreviewState() {
802
+ this.previewAbort?.abort();
803
+ this.previewAbort = null;
804
+ this.previewGeneration += 1;
805
+ this.destroyPreviewHandles();
806
+ this.previewMemo = null;
807
+ }
808
+ destroyPreviewHandles() {
809
+ const handles = this.previewHandles;
810
+ this.previewHandles = [];
811
+ for (const handle of handles) {
812
+ try {
813
+ handle.destroy();
814
+ }
815
+ catch (error) {
816
+ this.options.onFailed?.(error);
817
+ }
818
+ }
819
+ }
820
+ updatePreview(snapshot, entryClaimed) {
821
+ if (entryClaimed) {
822
+ // The entry renderer owns the pane stack; release preview resources so
823
+ // a later fallback re-renders from scratch.
824
+ if (this.previewMemo || this.previewHandles.length > 0 || this.previewAbort) {
825
+ this.resetPreviewState();
826
+ this.elPreviewContent.replaceChildren();
827
+ }
828
+ // There is no built-in preview to wait for; release any waiters.
829
+ this.previewHydrated = true;
830
+ this.flushRenderedWaiters();
831
+ return;
832
+ }
833
+ const memo = this.previewMemo;
834
+ if (memo
835
+ && memo.path === snapshot.currentPath
836
+ && memo.pathType === snapshot.currentPathType
837
+ && memo.text === snapshot.currentText
838
+ && memo.colorScheme === this.colorScheme) {
839
+ // Nothing that feeds the preview changed; keep the existing DOM and any
840
+ // mounted extension handles.
841
+ return;
842
+ }
843
+ this.previewAbort?.abort();
844
+ this.previewAbort = null;
845
+ this.destroyPreviewHandles();
846
+ const generation = ++this.previewGeneration;
847
+ this.previewHydrated = false;
848
+ this.previewMemo = {
849
+ path: snapshot.currentPath,
850
+ pathType: snapshot.currentPathType,
851
+ text: snapshot.currentText,
852
+ colorScheme: this.colorScheme
853
+ };
854
+ if (snapshot.currentPathType !== 'markdown') {
855
+ if (snapshot.currentPathType === 'image' && this.assetSession) {
856
+ const abort = new AbortController();
857
+ this.previewAbort = abort;
858
+ void this.assetSession.resolve(snapshot.currentPath, snapshot.currentPath).then((src) => {
859
+ if (generation !== this.previewGeneration || abort.signal.aborted)
860
+ return;
861
+ this.elPreviewContent.innerHTML = src
862
+ ? `<div class="asset-preview-wrap"><img class="asset-preview-image" src="${escapeHtml(src)}" alt="${escapeHtml(snapshot.currentPath)}"></div>`
863
+ : renderMdzipPreviewHtml(snapshot);
864
+ this.firePreviewRendered(snapshot, generation);
865
+ this.fireAssetsHydrated(snapshot, generation);
866
+ }).catch((error) => this.options.onFailed?.(error));
867
+ }
868
+ else {
869
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
870
+ this.firePreviewRendered(snapshot, generation);
871
+ this.fireAssetsHydrated(snapshot, generation);
872
+ }
873
+ return;
874
+ }
875
+ const abort = new AbortController();
876
+ this.previewAbort = abort;
877
+ const context = this.createMarkdownContext(snapshot, abort.signal);
878
+ let result;
879
+ try {
880
+ result = this.renderingService.renderMarkdown(snapshot.currentText, context);
881
+ }
882
+ catch (error) {
883
+ this.options.onFailed?.(error);
884
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
885
+ this.firePreviewRendered(snapshot, generation);
886
+ this.fireAssetsHydrated(snapshot, generation);
887
+ return;
888
+ }
889
+ if (typeof result === 'string') {
890
+ // Sync fast path: no microtask hop when every pipeline stage is sync.
891
+ this.applyPreviewHtml(result, snapshot, context, generation);
892
+ return;
893
+ }
894
+ void result.then((html) => {
895
+ if (generation !== this.previewGeneration || abort.signal.aborted) {
896
+ return; // Stale: the selection or content moved on while rendering.
897
+ }
898
+ this.applyPreviewHtml(html, snapshot, context, generation);
899
+ }).catch((error) => {
900
+ if (generation !== this.previewGeneration || abort.signal.aborted) {
901
+ return;
902
+ }
903
+ if (error?.name !== 'AbortError') {
904
+ this.options.onFailed?.(error);
905
+ }
906
+ });
907
+ }
908
+ applyPreviewHtml(html, snapshot, context, generation) {
909
+ // When the preview references archive images, mount the text immediately
910
+ // and hydrate each image progressively (reserving layout space from its
911
+ // sniffed intrinsic size), rather than blocking the whole preview on image
912
+ // resolution. Other markdown mounts synchronously.
913
+ if (this.assetSession && /<img\b/i.test(html)) {
914
+ this.mountProgressivePreview(html, snapshot, context, generation);
915
+ return;
916
+ }
917
+ this.mountPreviewHtml(html, snapshot, context, generation);
918
+ }
919
+ mountPreviewHtml(html, snapshot, context, generation) {
920
+ this.elPreviewContent.innerHTML = html;
921
+ this.mountPreviewExtensions(context, generation);
922
+ // No archive images to resolve on this path: the preview is ready now.
923
+ this.firePreviewRendered(snapshot, generation);
924
+ this.fireAssetsHydrated(snapshot, generation);
925
+ }
926
+ /**
927
+ * Mounts the rendered text immediately with image placeholders, then swaps
928
+ * each archive image in as it resolves. `onPreviewRendered` fires once the
929
+ * text is in the DOM; `onAssetsHydrated` fires once every referenced image
930
+ * has resolved and had its final `src` assigned (or there are none).
931
+ */
932
+ mountProgressivePreview(html, snapshot, context, generation) {
933
+ this.elPreviewContent.innerHTML = html;
934
+ const session = this.assetSession;
935
+ const document = this.elPreviewContent.ownerDocument;
936
+ const pending = [];
937
+ for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
938
+ const source = image.getAttribute('src');
939
+ // Leave external, protocol-relative, data, and fragment URLs untouched.
940
+ if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
941
+ continue;
942
+ }
943
+ // Drop the archive-relative src so the browser does not fetch the bad
944
+ // path. Wrap the image in a collapsed slot so the text stays compact and
945
+ // immediately readable; the slot eases open to the reserved height once
946
+ // the image resolves.
947
+ image.removeAttribute('src');
948
+ image.classList.add('mdzip-image-loading');
949
+ const slot = document.createElement('span');
950
+ slot.className = 'mdzip-image-slot';
951
+ image.parentNode?.insertBefore(slot, image);
952
+ slot.appendChild(image);
953
+ pending.push({ image, slot, source });
954
+ }
955
+ this.mountPreviewExtensions(context, generation);
956
+ this.firePreviewRendered(snapshot, generation);
957
+ if (!session || pending.length === 0) {
958
+ this.fireAssetsHydrated(snapshot, generation);
959
+ return;
960
+ }
961
+ let remaining = pending.length;
962
+ const settle = () => {
963
+ remaining -= 1;
964
+ if (remaining === 0) {
965
+ this.fireAssetsHydrated(snapshot, generation);
966
+ }
967
+ };
968
+ for (const { image, slot, source } of pending) {
969
+ void session.resolveImage(source, context.currentPath).then((resolved) => {
970
+ if (generation !== this.previewGeneration || context.signal.aborted) {
971
+ settle();
972
+ return;
973
+ }
974
+ if (!resolved) {
975
+ image.classList.remove('mdzip-image-loading');
976
+ this.openImageSlot(slot);
977
+ settle();
978
+ return;
979
+ }
980
+ // Size the reserved box from the sniffed dimensions so the slot eases
981
+ // open to the image's exact height in a single slide — and the pixels
982
+ // drop into an already-correct box with no further reflow.
983
+ if (resolved.width && resolved.height) {
984
+ image.setAttribute('width', String(resolved.width));
985
+ image.setAttribute('height', String(resolved.height));
986
+ }
987
+ const clear = () => image.classList.remove('mdzip-image-loading');
988
+ image.addEventListener('load', clear, { once: true });
989
+ image.addEventListener('error', clear, { once: true });
990
+ image.setAttribute('src', resolved.url);
991
+ this.openImageSlot(slot);
992
+ settle();
993
+ }).catch((error) => {
994
+ if (error?.name !== 'AbortError') {
995
+ this.options.onFailed?.(error);
996
+ }
997
+ image.classList.remove('mdzip-image-loading');
998
+ this.openImageSlot(slot);
999
+ settle();
1000
+ });
1001
+ }
1002
+ }
1003
+ /**
1004
+ * Reveals a collapsed image slot. Flushing layout before toggling the class
1005
+ * lets the `0fr -> 1fr` grid transition run from the collapsed state instead
1006
+ * of being coalesced into the initial mount; CSS snaps it open instantly
1007
+ * under `prefers-reduced-motion`.
1008
+ */
1009
+ openImageSlot(slot) {
1010
+ void slot.offsetHeight;
1011
+ slot.classList.add('mdzip-image-open');
1012
+ }
1013
+ mountPreviewExtensions(context, generation) {
1014
+ for (const extension of this.markdownExtensions) {
1015
+ if (!extension.mount) {
1016
+ continue;
1017
+ }
1018
+ try {
1019
+ const mounted = extension.mount(this.elPreviewContent, context);
1020
+ if (isThenable(mounted)) {
1021
+ void Promise.resolve(mounted).then((handle) => {
1022
+ if (!handle) {
1023
+ return;
1024
+ }
1025
+ if (generation !== this.previewGeneration) {
1026
+ try {
1027
+ handle.destroy();
1028
+ }
1029
+ catch {
1030
+ // Stale handle cleanup failure is not actionable.
1031
+ }
1032
+ return;
1033
+ }
1034
+ this.previewHandles.push(handle);
1035
+ }).catch((error) => {
1036
+ if (generation === this.previewGeneration) {
1037
+ this.options.onFailed?.(error);
1038
+ }
1039
+ });
1040
+ }
1041
+ else if (mounted) {
1042
+ this.previewHandles.push(mounted);
1043
+ }
1044
+ }
1045
+ catch (error) {
1046
+ this.options.onFailed?.(error);
1047
+ }
1048
+ }
1049
+ }
1050
+ firePreviewRendered(snapshot, generation) {
1051
+ if (generation !== this.previewGeneration) {
1052
+ return;
1053
+ }
1054
+ try {
1055
+ this.options.onPreviewRendered?.(snapshot);
1056
+ }
1057
+ catch (error) {
1058
+ this.options.onFailed?.(error);
1059
+ }
1060
+ }
1061
+ fireAssetsHydrated(snapshot, generation) {
1062
+ if (generation !== this.previewGeneration) {
1063
+ return;
1064
+ }
1065
+ this.previewHydrated = true;
1066
+ try {
1067
+ this.options.onAssetsHydrated?.(snapshot);
1068
+ }
1069
+ catch (error) {
1070
+ this.options.onFailed?.(error);
1071
+ }
1072
+ this.flushRenderedWaiters();
1073
+ }
1074
+ flushRenderedWaiters() {
1075
+ const waiters = this.renderedWaiters;
1076
+ this.renderedWaiters = [];
1077
+ for (const resolve of waiters) {
1078
+ resolve();
1079
+ }
1080
+ }
1081
+ /**
1082
+ * Resolves once the current preview (including any images) is mounted and
1083
+ * hydrated. Resolves immediately when the latest preview is already ready.
1084
+ * Useful for revealing or animating content without observing private DOM.
1085
+ */
1086
+ whenRendered() {
1087
+ if (this.previewHydrated) {
1088
+ return Promise.resolve();
1089
+ }
1090
+ return new Promise((resolve) => this.renderedWaiters.push(resolve));
1091
+ }
1092
+ createMarkdownContext(snapshot, signal) {
1093
+ return {
1094
+ currentPath: snapshot.currentPath,
1095
+ sourceFormat: snapshot.sourceFormat,
1096
+ colorScheme: this.colorScheme,
1097
+ mode: snapshot.mode,
1098
+ manifest: snapshot.content.manifest,
1099
+ assetResolver: {
1100
+ resolveAssetUrl: (path) => this.assetSession?.resolveKnown(path, snapshot.currentPath)
1101
+ },
1102
+ signal
1103
+ };
1104
+ }
1105
+ /**
1106
+ * Entry renderer lifecycle. Renders are keyed by
1107
+ * (path, pathType, mode, sourceFormat): same key keeps the mounted handle
1108
+ * (calling `update()` when colorScheme or manifest changed), a changed key
1109
+ * destroys it and re-runs matching. Rename/move/delete of the backing entry
1110
+ * changes the path and is therefore handled as a selection change.
1111
+ */
1112
+ syncEntryRenderer(snapshot) {
1113
+ if (this.entryRenderers.length === 0) {
1114
+ return false;
1115
+ }
1116
+ const matchKey = [
1117
+ snapshot.currentPath,
1118
+ snapshot.currentPathType,
1119
+ snapshot.mode,
1120
+ snapshot.sourceFormat
1121
+ ].join('\u0000');
1122
+ if (this.entryState?.key === matchKey) {
1123
+ this.maybeUpdateEntryRenderer(snapshot);
1124
+ return true;
1125
+ }
1126
+ if (this.entryState) {
1127
+ this.teardownEntryRenderer();
1128
+ }
1129
+ if (this.entryMatchMissKey === matchKey) {
1130
+ return false;
1131
+ }
1132
+ const abort = new AbortController();
1133
+ const context = this.createEntryContext(snapshot, abort.signal);
1134
+ const renderer = this.matchEntryRenderer(context);
1135
+ if (!renderer) {
1136
+ this.entryMatchMissKey = matchKey;
1137
+ return false;
1138
+ }
1139
+ this.entryMatchMissKey = null;
1140
+ const generation = ++this.entryGeneration;
1141
+ const state = {
1142
+ key: matchKey,
1143
+ renderer,
1144
+ handle: null,
1145
+ abort,
1146
+ lastColorScheme: this.colorScheme,
1147
+ lastManifest: snapshot.content.manifest
1148
+ };
1149
+ this.entryState = state;
1150
+ this.elEntryPane.replaceChildren();
1151
+ try {
1152
+ const mounted = renderer.mount(this.elEntryPane, context);
1153
+ if (isThenable(mounted)) {
1154
+ void Promise.resolve(mounted).then((handle) => {
1155
+ if (this.entryGeneration !== generation || this.entryState !== state) {
1156
+ // Stale mount: the selection moved on while mounting.
1157
+ try {
1158
+ handle?.destroy();
1159
+ }
1160
+ catch {
1161
+ // Stale handle cleanup failure is not actionable.
1162
+ }
1163
+ return;
1164
+ }
1165
+ state.handle = handle ?? null;
1166
+ }).catch((error) => {
1167
+ if (this.entryGeneration === generation) {
1168
+ this.options.onFailed?.(error);
1169
+ }
1170
+ });
1171
+ }
1172
+ else {
1173
+ state.handle = mounted ?? null;
1174
+ }
1175
+ }
1176
+ catch (error) {
1177
+ this.options.onFailed?.(error);
1178
+ }
1179
+ return true;
1180
+ }
1181
+ maybeUpdateEntryRenderer(snapshot) {
1182
+ const state = this.entryState;
1183
+ if (!state) {
1184
+ return;
1185
+ }
1186
+ const manifest = snapshot.content.manifest;
1187
+ if (state.lastColorScheme === this.colorScheme && state.lastManifest === manifest) {
1188
+ return;
1189
+ }
1190
+ state.lastColorScheme = this.colorScheme;
1191
+ state.lastManifest = manifest;
1192
+ if (!state.handle?.update) {
1193
+ return;
1194
+ }
1195
+ const context = this.createEntryContext(snapshot, state.abort.signal);
1196
+ try {
1197
+ const result = state.handle.update(context);
1198
+ if (isThenable(result)) {
1199
+ void Promise.resolve(result).catch((error) => this.options.onFailed?.(error));
1200
+ }
1201
+ }
1202
+ catch (error) {
1203
+ this.options.onFailed?.(error);
1204
+ }
1205
+ }
1206
+ matchEntryRenderer(context) {
1207
+ const byPriority = [...this.entryRenderers]
1208
+ .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
1209
+ for (const renderer of byPriority) {
1210
+ try {
1211
+ if (renderer.matches(context)) {
1212
+ return renderer;
1213
+ }
1214
+ }
1215
+ catch (error) {
1216
+ this.options.onFailed?.(error);
1217
+ }
1218
+ }
1219
+ return null;
1220
+ }
1221
+ teardownEntryRenderer() {
1222
+ const active = this.entryState;
1223
+ if (!active) {
1224
+ return;
1225
+ }
1226
+ this.entryState = null;
1227
+ this.entryGeneration += 1;
1228
+ active.abort.abort();
1229
+ try {
1230
+ active.handle?.destroy();
1231
+ }
1232
+ catch (error) {
1233
+ this.options.onFailed?.(error);
1234
+ }
1235
+ this.elEntryPane.replaceChildren();
1236
+ }
1237
+ createEntryContext(snapshot, signal) {
1238
+ const workspace = this.workspace;
1239
+ const path = snapshot.currentPath;
1240
+ return {
1241
+ path,
1242
+ pathType: snapshot.currentPathType,
1243
+ mode: snapshot.mode,
1244
+ sourceFormat: snapshot.sourceFormat,
1245
+ colorScheme: this.colorScheme,
1246
+ manifest: snapshot.content.manifest,
1247
+ snapshot,
1248
+ signal,
1249
+ readBytes: async () => {
1250
+ const bytes = await workspace?.readPathBytes(path);
1251
+ if (!bytes) {
1252
+ throw new Error(`Cannot read bytes for "${path}".`);
1253
+ }
1254
+ return bytes;
1255
+ },
1256
+ updateManifest: async (manifest) => {
1257
+ if (!workspace) {
1258
+ throw new Error('Workspace is not open.');
1259
+ }
1260
+ await workspace.updateManifest(manifest);
1261
+ }
1262
+ };
1263
+ }
682
1264
  createCmEditor(parent, initialText, mode) {
683
1265
  // eslint-disable-next-line @typescript-eslint/no-this-alias
684
1266
  const self = this;
@@ -754,6 +1336,15 @@ export class MdzipWorkspaceView {
754
1336
  }
755
1337
  return editor;
756
1338
  }
1339
+ async ensureCmEditor(force = false) {
1340
+ const snapshot = this.workspace?.snapshot();
1341
+ if (this.cmEditor || !snapshot || (!force && this.layout === 'preview')
1342
+ || !canShowSourceLayout(snapshot)) {
1343
+ return this.cmEditor;
1344
+ }
1345
+ this.cmEditor = this.createCmEditor(this.elEditorHost, snapshot.currentText, snapshot.mode);
1346
+ return this.cmEditor;
1347
+ }
757
1348
  render() {
758
1349
  const snapshot = this.workspace?.snapshot() ?? null;
759
1350
  this.elEmptyState.hidden = snapshot !== null;
@@ -764,7 +1355,9 @@ export class MdzipWorkspaceView {
764
1355
  return;
765
1356
  }
766
1357
  this.layout = this.validLayoutForSnapshot(this.layout, snapshot);
767
- const canEdit = canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, snapshot.mode);
1358
+ const entryClaimed = this.syncEntryRenderer(snapshot);
1359
+ const canEdit = !entryClaimed
1360
+ && canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, snapshot.mode);
768
1361
  const canShowSource = canShowSourceLayout(snapshot);
769
1362
  const showNavigationControl = this.controlPolicy.navigation;
770
1363
  const showTitleControl = this.controlPolicy.title.visible;
@@ -877,14 +1470,16 @@ export class MdzipWorkspaceView {
877
1470
  effects: this.readOnlyCompartment.reconfigure(EditorState.readOnly.of(snapshot.mode === 'read-only'))
878
1471
  });
879
1472
  }
880
- this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
1473
+ this.updatePreview(snapshot, entryClaimed);
881
1474
  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');
1475
+ const showEdit = !entryClaimed && (pt === 'markdown' || pt === 'text') && this.layout !== 'preview';
1476
+ const showPreview = !entryClaimed && (pt === 'image' || pt === 'binary' || pt === 'text'
1477
+ || (pt === 'markdown' && this.layout !== 'source'));
885
1478
  this.elEditPane.classList.toggle('active', showEdit);
886
1479
  this.elPreviewPane.classList.toggle('active', showPreview);
887
- this.elPaneStack.classList.toggle('split-mode', this.layout === 'split');
1480
+ this.elEntryPane.classList.toggle('active', entryClaimed);
1481
+ this.elPaneStack.classList.toggle('split-mode', this.layout === 'split' && !entryClaimed);
1482
+ this.elPaneStack.classList.toggle('entry-claimed', entryClaimed);
888
1483
  if (!showTitleControl) {
889
1484
  this.titleDialogOpen = false;
890
1485
  this.metadataDialogOpen = false;
@@ -1026,17 +1621,17 @@ export class MdzipWorkspaceView {
1026
1621
  });
1027
1622
  this.elPreviewBtn.addEventListener('click', () => {
1028
1623
  if (this.controlPolicy.layout.preview) {
1029
- this.setLayout('preview');
1624
+ void this.setLayout('preview');
1030
1625
  }
1031
1626
  });
1032
1627
  this.elSplitBtn.addEventListener('click', () => {
1033
1628
  if (this.controlPolicy.layout.split) {
1034
- this.setLayout('split');
1629
+ void this.setLayout('split');
1035
1630
  }
1036
1631
  });
1037
1632
  this.elSourceBtn.addEventListener('click', () => {
1038
1633
  if (this.controlPolicy.layout.source) {
1039
- this.setLayout('source');
1634
+ void this.setLayout('source');
1040
1635
  }
1041
1636
  });
1042
1637
  this.elSaveBtn.addEventListener('click', () => {
@@ -1477,16 +2072,58 @@ export class MdzipWorkspaceView {
1477
2072
  ['Entry point', snapshot.sourceFormat === 'mdz' ? snapshot.content.entryPoint : 'Not applicable']
1478
2073
  ];
1479
2074
  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;
2075
+ return this.createMetadataRow(label, value);
2076
+ }));
2077
+ const libraries = [
2078
+ ...(this.options.libraries ?? []),
2079
+ ...MDZIP_RUNTIME_LIBRARIES
2080
+ ].filter((library, index, all) => all.findIndex((candidate) => candidate.name === library.name) === index);
2081
+ this.elLibraryList.replaceChildren(...libraries.map((library) => {
2082
+ return this.createLibraryRow(library);
1488
2083
  }));
1489
2084
  }
2085
+ createMetadataRow(label, value) {
2086
+ const row = this.elRoot.ownerDocument.createElement('div');
2087
+ row.className = 'metadata-row';
2088
+ const term = this.elRoot.ownerDocument.createElement('dt');
2089
+ term.textContent = label;
2090
+ const detail = this.elRoot.ownerDocument.createElement('dd');
2091
+ detail.textContent = value;
2092
+ row.append(term, detail);
2093
+ return row;
2094
+ }
2095
+ createLibraryRow(library) {
2096
+ const row = this.elRoot.ownerDocument.createElement('div');
2097
+ row.className = 'metadata-row library-row';
2098
+ const term = this.elRoot.ownerDocument.createElement('dt');
2099
+ const name = this.elRoot.ownerDocument.createElement('span');
2100
+ name.className = 'library-name';
2101
+ if (library.repositoryUrl) {
2102
+ const link = this.elRoot.ownerDocument.createElement('a');
2103
+ link.href = library.repositoryUrl;
2104
+ link.target = '_blank';
2105
+ link.rel = 'noopener noreferrer';
2106
+ link.textContent = library.name;
2107
+ link.setAttribute('aria-label', `${library.name} repository`);
2108
+ name.append(link);
2109
+ }
2110
+ else {
2111
+ name.textContent = library.name;
2112
+ }
2113
+ const version = this.elRoot.ownerDocument.createElement('span');
2114
+ version.className = 'library-version';
2115
+ version.textContent = library.version;
2116
+ term.append(name, version);
2117
+ const detail = this.elRoot.ownerDocument.createElement('dd');
2118
+ if (library.description) {
2119
+ const description = this.elRoot.ownerDocument.createElement('span');
2120
+ description.className = 'library-description';
2121
+ description.textContent = library.description;
2122
+ detail.append(description);
2123
+ }
2124
+ row.append(term, detail);
2125
+ return row;
2126
+ }
1490
2127
  requestMdzConversion(action) {
1491
2128
  const snapshot = this.workspace?.snapshot();
1492
2129
  if (!snapshot || snapshot.mode === 'read-only' || snapshot.sourceFormat !== 'markdown') {
@@ -1501,8 +2138,9 @@ export class MdzipWorkspaceView {
1501
2138
  return;
1502
2139
  }
1503
2140
  this.conversionHookPending = true;
2141
+ const context = this.createConversionContext(action);
1504
2142
  void Promise.resolve()
1505
- .then(() => hook(action))
2143
+ .then(() => hook(action, context))
1506
2144
  .then((handled) => {
1507
2145
  this.conversionHookPending = false;
1508
2146
  if (!handled) {
@@ -1515,6 +2153,75 @@ export class MdzipWorkspaceView {
1515
2153
  this.openConversionDialog(action);
1516
2154
  });
1517
2155
  }
2156
+ createConversionContext(action) {
2157
+ const workspace = this.workspace;
2158
+ const snapshot = workspace?.snapshot();
2159
+ const selection = this.cmEditor?.state.selection.main;
2160
+ const captured = workspace && snapshot ? {
2161
+ workspace,
2162
+ documentGeneration: this.conversionDocumentGeneration,
2163
+ path: snapshot.currentPath,
2164
+ text: snapshot.currentText,
2165
+ selectionStart: selection?.from,
2166
+ selectionEnd: selection?.to
2167
+ } : null;
2168
+ let consumed = false;
2169
+ const take = () => {
2170
+ if (consumed || !captured || this.workspace !== captured.workspace
2171
+ || this.conversionDocumentGeneration !== captured.documentGeneration) {
2172
+ return null;
2173
+ }
2174
+ const current = captured.workspace.snapshot();
2175
+ if (current.mode === 'read-only'
2176
+ || current.sourceFormat !== 'markdown'
2177
+ || current.currentPath !== captured.path
2178
+ || current.currentText !== captured.text) {
2179
+ return null;
2180
+ }
2181
+ consumed = true;
2182
+ return captured;
2183
+ };
2184
+ return {
2185
+ insertMarkdown: async (text) => {
2186
+ const target = take();
2187
+ if (!target || target.selectionStart === undefined || target.selectionEnd === undefined) {
2188
+ return false;
2189
+ }
2190
+ const nextText = target.text.slice(0, target.selectionStart)
2191
+ + text
2192
+ + target.text.slice(target.selectionEnd);
2193
+ target.workspace.editText(nextText);
2194
+ this.render();
2195
+ const editor = await this.ensureCmEditor();
2196
+ if (editor) {
2197
+ editor.dispatch({ selection: { anchor: target.selectionStart + text.length } });
2198
+ editor.focus();
2199
+ }
2200
+ return true;
2201
+ },
2202
+ convertToMdz: async () => {
2203
+ const target = take();
2204
+ if (!target || !await this.convertToMdz()) {
2205
+ return false;
2206
+ }
2207
+ if (action.kind === 'navigation') {
2208
+ this.navVisible = true;
2209
+ this.render();
2210
+ return true;
2211
+ }
2212
+ const editor = await this.ensureCmEditor();
2213
+ if (editor && target.selectionStart !== undefined) {
2214
+ editor.dispatch({ selection: { anchor: target.selectionStart } });
2215
+ }
2216
+ if (action.kind === 'image-file') {
2217
+ await this.insertImageFile(action.file);
2218
+ return true;
2219
+ }
2220
+ this.elImageInput.click();
2221
+ return true;
2222
+ }
2223
+ };
2224
+ }
1518
2225
  openConversionDialog(action) {
1519
2226
  this.conversionAction = action;
1520
2227
  this.render();
@@ -1857,9 +2564,10 @@ export class MdzipWorkspaceView {
1857
2564
  this.setColorScheme(colorScheme);
1858
2565
  this.options.onColorSchemeChanged?.(colorScheme);
1859
2566
  }
1860
- setLayout(requested) {
2567
+ async setLayout(requested) {
1861
2568
  const snapshot = this.workspace?.snapshot();
1862
2569
  this.layout = snapshot ? this.validLayoutForSnapshot(requested, snapshot) : requested;
2570
+ await this.ensureCmEditor();
1863
2571
  this.render();
1864
2572
  }
1865
2573
  navMenuTargetFromElement(element) {
@@ -2609,6 +3317,7 @@ const SHELL_HTML = `
2609
3317
  <section class="pane preview-pane" data-ref="preview-pane">
2610
3318
  <article class="preview-content" data-ref="preview-content"></article>
2611
3319
  </section>
3320
+ <section class="pane entry-pane" data-ref="entry-pane"></section>
2612
3321
  </div>
2613
3322
  </div>
2614
3323
 
@@ -2649,6 +3358,8 @@ const SHELL_HTML = `
2649
3358
  <div class="title-dialog metadata-dialog">
2650
3359
  <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
2651
3360
  <dl data-ref="metadata-list"></dl>
3361
+ <h4>Libraries</h4>
3362
+ <dl data-ref="library-list"></dl>
2652
3363
  <div class="title-dialog-actions">
2653
3364
  <button type="button" class="save-title" data-action="close-metadata">Close</button>
2654
3365
  </div>