@mdzip/editor 1.3.21 → 1.4.4

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 (74) hide show
  1. package/README.md +62 -0
  2. package/dist/archive-utils.d.ts.map +1 -1
  3. package/dist/archive-utils.js +16 -2
  4. package/dist/archive-utils.js.map +1 -1
  5. package/dist/asset-cache.d.ts +14 -0
  6. package/dist/asset-cache.d.ts.map +1 -1
  7. package/dist/asset-cache.js +57 -0
  8. package/dist/asset-cache.js.map +1 -1
  9. package/dist/front-matter-extension.d.ts +45 -0
  10. package/dist/front-matter-extension.d.ts.map +1 -0
  11. package/dist/front-matter-extension.js +114 -0
  12. package/dist/front-matter-extension.js.map +1 -0
  13. package/dist/front-matter.d.ts +18 -0
  14. package/dist/front-matter.d.ts.map +1 -0
  15. package/dist/front-matter.js +48 -0
  16. package/dist/front-matter.js.map +1 -0
  17. package/dist/highlight-core.d.ts +3 -0
  18. package/dist/highlight-core.d.ts.map +1 -0
  19. package/dist/highlight-core.js +53 -0
  20. package/dist/highlight-core.js.map +1 -0
  21. package/dist/image-edit.d.ts +26 -0
  22. package/dist/image-edit.d.ts.map +1 -0
  23. package/dist/image-edit.js +195 -0
  24. package/dist/image-edit.js.map +1 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/library-info.d.ts +2 -2
  30. package/dist/library-info.js +2 -2
  31. package/dist/library-info.js.map +1 -1
  32. package/dist/mdz-archive-worker-protocol.d.ts +36 -0
  33. package/dist/mdz-archive-worker-protocol.d.ts.map +1 -0
  34. package/dist/mdz-archive-worker-protocol.js +2 -0
  35. package/dist/mdz-archive-worker-protocol.js.map +1 -0
  36. package/dist/mdz-archive.worker-client.d.ts +30 -0
  37. package/dist/mdz-archive.worker-client.d.ts.map +1 -0
  38. package/dist/mdz-archive.worker-client.js +97 -0
  39. package/dist/mdz-archive.worker-client.js.map +1 -0
  40. package/dist/mdz-archive.worker.d.ts +2 -0
  41. package/dist/mdz-archive.worker.d.ts.map +1 -0
  42. package/dist/mdz-archive.worker.js +8870 -0
  43. package/dist/mdz-archive.worker.js.map +1 -0
  44. package/dist/mermaid.d.ts.map +1 -1
  45. package/dist/mermaid.js +60 -41
  46. package/dist/mermaid.js.map +1 -1
  47. package/dist/metadata.d.ts +1 -1
  48. package/dist/metadata.d.ts.map +1 -1
  49. package/dist/metadata.js +12 -2
  50. package/dist/metadata.js.map +1 -1
  51. package/dist/preview.d.ts +1 -0
  52. package/dist/preview.d.ts.map +1 -1
  53. package/dist/preview.js +1 -0
  54. package/dist/preview.js.map +1 -1
  55. package/dist/rendering.d.ts +114 -0
  56. package/dist/rendering.d.ts.map +1 -1
  57. package/dist/rendering.js +153 -1
  58. package/dist/rendering.js.map +1 -1
  59. package/dist/stats.d.ts +23 -0
  60. package/dist/stats.d.ts.map +1 -0
  61. package/dist/stats.js +31 -0
  62. package/dist/stats.js.map +1 -0
  63. package/dist/view-css.d.ts.map +1 -1
  64. package/dist/view-css.js +112 -0
  65. package/dist/view-css.js.map +1 -1
  66. package/dist/view.d.ts +421 -1
  67. package/dist/view.d.ts.map +1 -1
  68. package/dist/view.js +2219 -410
  69. package/dist/view.js.map +1 -1
  70. package/dist/workspace.d.ts +14 -0
  71. package/dist/workspace.d.ts.map +1 -1
  72. package/dist/workspace.js +53 -41
  73. package/dist/workspace.js.map +1 -1
  74. package/package.json +11 -5
package/dist/view.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
2
2
  import { markdown } from '@codemirror/lang-markdown';
3
- import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
3
+ import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language';
4
4
  import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemirror/search';
5
- import { Compartment, EditorState } from '@codemirror/state';
6
- import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
5
+ import { Compartment, EditorState, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
6
+ import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType, dropCursor, keymap, lineNumbers } from '@codemirror/view';
7
7
  import { tags } from '@lezer/highlight';
8
8
  import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
9
9
  import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
@@ -13,7 +13,9 @@ import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
13
13
  import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
14
14
  import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
15
15
  import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
16
- import { MdzipRenderingService, defaultSafeMarkdownRenderer } from './rendering.js';
16
+ import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
17
+ import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, tokenEmbedsImage, tokenIsHeading } from './rendering.js';
18
+ import { mdzipFrontMatterExtension } from './front-matter-extension.js';
17
19
  import { WORKSPACE_CSS } from './view-css.js';
18
20
  const STYLE_ATTR = 'data-mdzip-ws-styles';
19
21
  const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
@@ -31,6 +33,7 @@ const IMAGE_ICON_HTML = lucideIcon(FileImage, NAV_ICON_CLASS);
31
33
  const FILE_ICON_HTML = lucideIcon(File, NAV_ICON_CLASS);
32
34
  const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
33
35
  const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
36
+ const IMAGE_EDIT_AFFORDANCE_ICON_HTML = lucideIcon(SquarePen, 'mdzip-image-edit-affordance-icon');
34
37
  const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
35
38
  const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
36
39
  const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS} convert-mdz-icon`);
@@ -433,6 +436,29 @@ const mdzipEditorTheme = EditorView.theme({
433
436
  color: 'var(--mdzip-muted-foreground-color)',
434
437
  opacity: '0.65',
435
438
  },
439
+ '.mdzip-image-edit-affordance': {
440
+ display: 'inline-flex',
441
+ alignItems: 'center',
442
+ justifyContent: 'center',
443
+ width: '20px',
444
+ height: '20px',
445
+ marginLeft: '2px',
446
+ padding: '0',
447
+ verticalAlign: 'middle',
448
+ border: 'none',
449
+ borderRadius: '4px',
450
+ background: 'var(--mdzip-widget-background-color)',
451
+ color: 'var(--mdzip-muted-foreground-color)',
452
+ cursor: 'pointer',
453
+ },
454
+ '.mdzip-image-edit-affordance:hover': {
455
+ background: 'var(--mdzip-control-hover-background-color)',
456
+ color: 'var(--mdzip-control-foreground-color)',
457
+ },
458
+ '.mdzip-image-edit-affordance-icon': {
459
+ width: '13px',
460
+ height: '13px',
461
+ },
436
462
  '.cm-panels': {
437
463
  background: 'var(--mdzip-widget-background-color)',
438
464
  color: 'var(--mdzip-editor-foreground-color)',
@@ -555,6 +581,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
555
581
  }, {
556
582
  decorations: value => value.decorations
557
583
  });
584
+ // Fenced/indented code blocks, inline code spans, and link/image URLs aren't
585
+ // prose — the browser's spellchecker has no dictionary for shell syntax or
586
+ // filenames, so it just underlines everything. Syntax-tree node ranges (not
587
+ // a MatchDecorator regexp) are required here because MatchDecorator only
588
+ // matches within a single line, and fenced code blocks span many.
589
+ const NO_SPELLCHECK_NODE_NAMES = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'URL']);
590
+ const noSpellcheckMark = Decoration.mark({ attributes: { spellcheck: 'false' } });
591
+ function buildNoSpellcheckDecorations(view) {
592
+ const builder = new RangeSetBuilder();
593
+ for (const { from, to } of view.visibleRanges) {
594
+ syntaxTree(view.state).iterate({
595
+ from,
596
+ to,
597
+ enter: (node) => {
598
+ if (NO_SPELLCHECK_NODE_NAMES.has(node.name)) {
599
+ builder.add(node.from, node.to, noSpellcheckMark);
600
+ return false;
601
+ }
602
+ return true;
603
+ }
604
+ });
605
+ }
606
+ return builder.finish();
607
+ }
608
+ const noSpellcheckHighlight = ViewPlugin.fromClass(class {
609
+ constructor(view) {
610
+ this.decorations = buildNoSpellcheckDecorations(view);
611
+ }
612
+ update(update) {
613
+ if (update.docChanged || update.viewportChanged || syntaxTree(update.state) !== syntaxTree(update.startState)) {
614
+ this.decorations = buildNoSpellcheckDecorations(update.view);
615
+ }
616
+ }
617
+ }, {
618
+ decorations: value => value.decorations
619
+ });
620
+ // Click-to-edit affordance for existing image references. Populated
621
+ // on-click (not continuously, since the trigger is click not hover) via a
622
+ // StateEffect dispatched from the editor's `click` domEventHandler in
623
+ // createCmEditor. Fully inert unless a host imageEditHandler is set — see
624
+ // that click handler's own gate.
625
+ const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
626
+ const imageEditAffordanceEffect = StateEffect.define();
627
+ class ImageEditAffordanceWidget extends WidgetType {
628
+ constructor(from, to) {
629
+ super();
630
+ this.from = from;
631
+ this.to = to;
632
+ }
633
+ eq(other) {
634
+ return other.from === this.from && other.to === this.to;
635
+ }
636
+ toDOM() {
637
+ const btn = document.createElement('button');
638
+ btn.type = 'button';
639
+ btn.className = 'mdzip-image-edit-affordance';
640
+ btn.setAttribute('aria-label', 'Edit image');
641
+ btn.setAttribute('data-mdzip-image-edit-affordance', '');
642
+ btn.innerHTML = IMAGE_EDIT_AFFORDANCE_ICON_HTML;
643
+ // Buttons are natively focusable; without this, clicking one moves DOM
644
+ // focus onto it and blurs the CodeMirror content — which would clear
645
+ // (and remove from the DOM) this very widget via the blur handler below
646
+ // before its own click event has a chance to fire.
647
+ btn.addEventListener('mousedown', (event) => event.preventDefault());
648
+ btn.addEventListener('click', (event) => {
649
+ event.preventDefault();
650
+ event.stopPropagation();
651
+ btn.dispatchEvent(new CustomEvent(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, {
652
+ bubbles: true,
653
+ detail: { from: this.from, to: this.to }
654
+ }));
655
+ });
656
+ return btn;
657
+ }
658
+ }
659
+ function imageEditAffordanceDeco(from, to) {
660
+ return Decoration.set([Decoration.widget({ widget: new ImageEditAffordanceWidget(from, to), side: 1 }).range(to)]);
661
+ }
662
+ const imageEditAffordanceField = StateField.define({
663
+ create: () => Decoration.none,
664
+ update(value, tr) {
665
+ value = value.map(tr.changes);
666
+ if (tr.docChanged) {
667
+ value = Decoration.none;
668
+ }
669
+ for (const effect of tr.effects) {
670
+ if (effect.is(imageEditAffordanceEffect)) {
671
+ value = effect.value ? imageEditAffordanceDeco(effect.value.from, effect.value.to) : Decoration.none;
672
+ }
673
+ }
674
+ return value;
675
+ },
676
+ provide: (field) => EditorView.decorations.from(field)
677
+ });
558
678
  function injectStyles(doc) {
559
679
  const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
560
680
  if (existing) {
@@ -626,19 +746,19 @@ depth = 0) {
626
746
  : isImageFile(node.entry.path)
627
747
  ? IMAGE_ICON_HTML
628
748
  : FILE_ICON_HTML;
629
- const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
630
- <span class="nav-orphan-button" role="button" tabindex="0"
631
- title="Orphaned asset" aria-label="Orphaned asset actions"
632
- data-orphan-path="${safePath}">
633
- ${ORPHAN_ICON_HTML}
749
+ const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
750
+ <span class="nav-orphan-button" role="button" tabindex="0"
751
+ title="Orphaned asset" aria-label="Orphaned asset actions"
752
+ data-orphan-path="${safePath}">
753
+ ${ORPHAN_ICON_HTML}
634
754
  </span>` : '';
635
755
  const draggable = options.allowDrag && !isManifest;
636
- return `<button type="button" class="${classes}" title="${title}"
637
- data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
638
- ${guides}<span class="nav-caret"></span>
639
- <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
640
- ${orphanBtnHtml}
641
- <span class="nav-label">${safeName}</span>
756
+ return `<button type="button" class="${classes}" title="${title}"
757
+ data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
758
+ ${guides}<span class="nav-caret"></span>
759
+ <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
760
+ ${orphanBtnHtml}
761
+ <span class="nav-label">${safeName}</span>
642
762
  </button>`;
643
763
  }
644
764
  const childTrail = depth === 0 ? [] : [...trail, !isLast];
@@ -646,14 +766,14 @@ depth = 0) {
646
766
  .map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
647
767
  .join('');
648
768
  const pending = options.pendingFolders.has(node.path.toLowerCase());
649
- return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
650
- <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
651
- ${guides}<span class="nav-caret" aria-hidden="true"></span>
652
- <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
653
- <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
654
- <span class="nav-label">${escapeHtml(node.name)}</span>
655
- </summary>
656
- <div class="nav-directory-children">${children}</div>
769
+ return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
770
+ <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
771
+ ${guides}<span class="nav-caret" aria-hidden="true"></span>
772
+ <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
773
+ <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
774
+ <span class="nav-label">${escapeHtml(node.name)}</span>
775
+ </summary>
776
+ <div class="nav-directory-children">${children}</div>
657
777
  </details>`;
658
778
  }
659
779
  // Inserts view-local pending (not yet saved) folders into the nav tree so they
@@ -719,6 +839,8 @@ function rawHtmlImageAlignClass(image) {
719
839
  return null;
720
840
  }
721
841
  export class MdzipWorkspaceView {
842
+ static { this.SCROLL_SYNC_SETTLE_MS = 250; }
843
+ static { this.SCROLL_SYNC_GESTURE_WINDOW_MS = 500; }
722
844
  constructor(container, options = {}) {
723
845
  this.workspace = null;
724
846
  this.assetSession = null;
@@ -738,6 +860,60 @@ export class MdzipWorkspaceView {
738
860
  // trigger) there's no "currently open document" to race against and no
739
861
  // de-dupe/staleness guard is needed.
740
862
  this.packFilesDialogState = null;
863
+ // Tracks an in-progress progressive (chunked) preview render so Copy All
864
+ // can force-drain whatever's left unmounted. `cursor` is how many of
865
+ // `records` are mounted so far; null once everything's mounted (or when
866
+ // progressive rendering isn't active at all) — that's Copy All's signal
867
+ // to skip the dialog and copy instantly. `sentinelHandle` is the
868
+ // scroll-driven continuation's IntersectionObserver, if one is currently
869
+ // armed; Copy All tears it down before manually draining so the two don't
870
+ // race and double-mount the same chunk.
871
+ //
872
+ // Each record carries its own mounted DOM root and extension/code-block
873
+ // handles (rather than those living only in the flat `previewHandles`
874
+ // array below) so that a same-document text edit can reconcile against the
875
+ // *previous* render's records: chunks whose `chunkSourceKey` didn't change
876
+ // keep their existing `root`/`handles` untouched — see
877
+ // `applyChunkReconciliation`.
878
+ this.chunkedRenderState = null;
879
+ // Per-generation memo of renderChunk's raw HTML output, keyed by chunk
880
+ // index, shared between the DOM-mount path (renderAndMountChunkBatch) and
881
+ // Copy All with Images (renderFullDocumentHtml) so whichever renders a
882
+ // given chunk first is the only one that pays for it. A chunk's HTML is
883
+ // written at most once per generation, never recomputed-and-compared: the
884
+ // shipped mermaid extension mints each diagram's SVG id from a counter
885
+ // that lives on the extension instance for the view's whole lifetime, so
886
+ // re-rendering the same chunk twice yields different-but-valid output —
887
+ // only a genuine write-once cache is safe to share across call sites.
888
+ // Deliberately a separate field from chunkedRenderState (not nested in
889
+ // it): that becomes null as soon as every chunk is mounted, but this
890
+ // needs to stay alive for the whole generation, including after mounting
891
+ // finishes — an idle, fully-mounted document is exactly when Copy All
892
+ // should get full reuse.
893
+ this.chunkHtmlCache = null;
894
+ // Promise-based like packFilesDialogState, but there's no caller awaiting
895
+ // a decision here — `abort` is Copy All's own cancellation switch, wired
896
+ // to the dialog's Cancel button. `label` distinguishes Copy All's single
897
+ // "Rendering the full document" phase from Copy All with Images' two
898
+ // phases ("Rendering the document" then "Embedding images").
899
+ this.copyRenderDialogState = null;
900
+ // Third state sharing the same dialog element as copyRenderDialogState
901
+ // (all three are mutually exclusive) — set once rendering/embedding
902
+ // finishes for a copy that showed the progress dialog. The clipboard
903
+ // write itself is deliberately *not* fired automatically: the async
904
+ // Clipboard API requires a recent user gesture, and by the time a
905
+ // multi-second (sometimes multi-minute) prepare phase finishes, the
906
+ // gesture that triggered Copy All has expired — Chrome rejects the write
907
+ // with "blocked due to lack of user activation". Waiting for the user to
908
+ // click the dialog's own Copy button gives the write a fresh gesture to
909
+ // run inside. `perform` does the actual write when that happens.
910
+ this.copyReadyState = null;
911
+ // Set once a copy that showed the progress dialog finishes (a rejection
912
+ // included — see `copyReadyState` above for why that write happens from
913
+ // a button click, not automatically), so the completion message stays up
914
+ // until the user dismisses it, rather than a fleeting toast easy to miss
915
+ // after a long wait.
916
+ this.copyRenderDoneState = null;
741
917
  this.navPaneWidth = 280;
742
918
  this.splitRatio = 0.5;
743
919
  this.resizing = false;
@@ -763,6 +939,12 @@ export class MdzipWorkspaceView {
763
939
  this.tooltipState = null;
764
940
  this.tooltipShowTimer = null;
765
941
  this.tooltipHideTimer = null;
942
+ this.copyToastHideTimer = null;
943
+ // Not readonly: tests override these to keep the "clipboard write hangs
944
+ // forever" case fast rather than actually waiting out the real production
945
+ // timeouts.
946
+ this.clipboardWriteTimeoutMs = 30000;
947
+ this.clipboardFallbackWriteTimeoutMs = 15000;
766
948
  this.cmEditor = null;
767
949
  this.readOnlyCompartment = new Compartment();
768
950
  this.lineNumbersCompartment = new Compartment();
@@ -774,7 +956,64 @@ export class MdzipWorkspaceView {
774
956
  // syncScrollFromPreview/syncScrollToPreview).
775
957
  this.lastSyncedEditorScrollTop = null;
776
958
  this.lastSyncedPreviewScrollTop = null;
959
+ // Timestamp of the most recent updatePreview() call (edit-driven or
960
+ // otherwise). A content change that alters a mounted element's rendered
961
+ // height (a chunk reconcile re-rendering a mermaid diagram, an image
962
+ // finishing decode, CodeMirror re-measuring line heights after the edit
963
+ // that triggered it) can cause a 'scroll' event with no explicit write
964
+ // behind it — the browser's own scroll anchoring, or CodeMirror's internal
965
+ // viewport/anchor recalculation, adjusting scrollTop on its own to keep
966
+ // content visually pinned. That's indistinguishable from a real user
967
+ // scroll to the listeners below, so within a brief window after a content
968
+ // change we treat scroll events as noise from settling layout rather than
969
+ // sync-worthy user intent — otherwise this class's own ratio-based writes
970
+ // (raw pixel values that don't correspond to any document position
971
+ // CodeMirror has actually measured) can themselves destabilize
972
+ // CodeMirror's virtualized viewport measurement mid-settle.
973
+ this.lastContentChangeTime = 0;
974
+ // Timestamp of the most recent genuine user-input gesture (wheel, touch,
975
+ // mousedown — including a scrollbar-thumb drag, which lands on the same
976
+ // element — or a key press) on each pane. Content-change-adjacent 'scroll'
977
+ // events (see lastContentChangeTime above) turned out to be one of several
978
+ // distinct ways a non-user-driven scroll can fire: native CSS scroll
979
+ // anchoring reacting to an unrelated element's height changing — a chunk
980
+ // reconcile, an image decode, or (confirmed live) an image/mermaid reveal
981
+ // transition finishing several seconds after the edit that triggered it —
982
+ // can smoothly drive scrollTop, frame by frame, all the way to a pane's
983
+ // absolute end, entirely decoupled from any edit and well outside any
984
+ // settle window. Rather than continuing to special-case each newly
985
+ // discovered non-user cause after the fact, sync is now gated on positive
986
+ // evidence of real user intent: a 'scroll' event is only treated as
987
+ // sync-worthy if a genuine input gesture on that same pane happened very
988
+ // recently. Everything else — anchoring, our own reconcile restores,
989
+ // CodeMirror's internal viewport correction — is left alone by definition.
990
+ this.lastEditorGestureTime = 0;
991
+ this.lastPreviewGestureTime = 0;
992
+ // Every preview re-render — whether reconciled or a full cold-start reset
993
+ // (mountChunkedPreview unconditionally does `elPreviewContent.
994
+ // replaceChildren()`) — has a real window where the preview is shorter
995
+ // than before (old DOM torn down, replacement mounted asynchronously: a
996
+ // chunk reconcile, a full reset, mermaid's async completion). If the
997
+ // current scroll position no longer fits that transient shrink, the
998
+ // browser clamps scrollTop to what does fit and never un-clamps once the
999
+ // replacement's DOM restores the true height. Captured once per
1000
+ // updatePreview() call (before anything moves), restored in
1001
+ // firePreviewRendered — the one point every rendering path (reconcile,
1002
+ // cold-start, progressive, eager, mermaid-triggered or not) already calls
1003
+ // once its content is back in the DOM.
1004
+ this.pendingPreviewScrollRestore = null;
1005
+ // Guards syncScrollToPreviewBottom against overlapping drains: set to the
1006
+ // chunkedRenderState generation currently being force-drained, null when
1007
+ // none is in flight. A second bottom-edge sync that arrives mid-drain
1008
+ // (e.g. repeated wheel events once the editor is already pinned at max
1009
+ // scrollTop) just no-ops — the in-flight call already owns finishing the
1010
+ // job for that generation.
1011
+ this.bottomDrainGeneration = null;
1012
+ // Non-null while the scroll-to-bottom catch-up toast is showing (past its
1013
+ // debounce) — see syncScrollToPreviewBottom.
1014
+ this.scrollCatchUpState = null;
777
1015
  this.markdownExtensions = [];
1016
+ this.frontMatterOptions = {};
778
1017
  this.entryRenderers = [];
779
1018
  this.renderingService = new MdzipRenderingService();
780
1019
  // Preview render memo: the preview pipeline only re-runs when one of these
@@ -797,6 +1036,7 @@ export class MdzipWorkspaceView {
797
1036
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
798
1037
  this.navigationMode = options.navigationMode ?? 'editor';
799
1038
  this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
1039
+ this.progressiveTextRendering = options.progressiveTextRendering ?? false;
800
1040
  this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
801
1041
  this.contentDensity = options.contentDensity ?? 'comfortable';
802
1042
  this.previewMaxWidth = options.previewMaxWidth;
@@ -808,8 +1048,9 @@ export class MdzipWorkspaceView {
808
1048
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
809
1049
  this.markdownRenderer = options.markdownRenderer;
810
1050
  this.markdownExtensions = options.markdownExtensions ?? [];
1051
+ this.frontMatterOptions = options.frontMatter ?? {};
811
1052
  this.entryRenderers = options.entryRenderers ?? [];
812
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1053
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
813
1054
  injectStyles(container.ownerDocument);
814
1055
  container.replaceChildren();
815
1056
  container.innerHTML = SHELL_HTML;
@@ -872,6 +1113,17 @@ export class MdzipWorkspaceView {
872
1113
  this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
873
1114
  this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
874
1115
  this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
1116
+ this.elCopyRenderDialog = q('[data-ref="copy-render-dialog"]');
1117
+ this.elCopyRenderHeading = q('[data-ref="copy-render-heading"]');
1118
+ this.elCopyRenderProgressSection = q('[data-ref="copy-render-progress-section"]');
1119
+ this.elCopyRenderProgressBar = q('[data-ref="copy-render-progress-bar"]');
1120
+ this.elCopyRenderProgressText = q('[data-ref="copy-render-progress-text"]');
1121
+ this.elCopyRenderReadyText = q('[data-ref="copy-render-ready-text"]');
1122
+ this.elCopyRenderDoneText = q('[data-ref="copy-render-done-text"]');
1123
+ this.elCopyRenderCancelBtn = q('[data-ref="copy-render-cancel-btn"]');
1124
+ this.elCopyRenderReadyCancelBtn = q('[data-ref="copy-render-ready-cancel-btn"]');
1125
+ this.elCopyRenderConfirmBtn = q('[data-ref="copy-render-confirm-btn"]');
1126
+ this.elCopyRenderDismissBtn = q('[data-ref="copy-render-dismiss-btn"]');
875
1127
  this.elNavMenu = q('[data-ref="nav-menu"]');
876
1128
  this.elNameDialog = q('[data-ref="name-dialog"]');
877
1129
  this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
@@ -883,6 +1135,7 @@ export class MdzipWorkspaceView {
883
1135
  this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
884
1136
  this.elReplaceInput = q('[data-ref="replace-input"]');
885
1137
  this.elTooltip = q('[data-ref="tooltip"]');
1138
+ this.elCopyToast = q('[data-ref="copy-toast"]');
886
1139
  this.elEmptyState = q('[data-ref="empty-state"]');
887
1140
  this.prepareTooltips();
888
1141
  this.attachEvents();
@@ -904,6 +1157,7 @@ export class MdzipWorkspaceView {
904
1157
  this.resetRenderingState();
905
1158
  this.cmEditor?.destroy();
906
1159
  this.cmEditor = null;
1160
+ this.workspace?.dispose();
907
1161
  this.workspace = null;
908
1162
  this.replaceAssetSession(null);
909
1163
  this.conversionDocumentGeneration += 1;
@@ -985,6 +1239,7 @@ export class MdzipWorkspaceView {
985
1239
  this.resetRenderingState();
986
1240
  this.cmEditor?.destroy();
987
1241
  this.cmEditor = null;
1242
+ this.workspace?.dispose();
988
1243
  this.workspace = null;
989
1244
  this.replaceAssetSession(null);
990
1245
  this.conversionDocumentGeneration += 1;
@@ -1178,6 +1433,12 @@ export class MdzipWorkspaceView {
1178
1433
  this.teardownEntryRenderer();
1179
1434
  // Release any whenRendered() waiters so their promises do not hang.
1180
1435
  this.flushRenderedWaiters();
1436
+ try {
1437
+ this.workspace?.dispose();
1438
+ }
1439
+ catch {
1440
+ // Ignore worker teardown errors
1441
+ }
1181
1442
  try {
1182
1443
  this.cmEditor?.destroy();
1183
1444
  }
@@ -1204,15 +1465,46 @@ export class MdzipWorkspaceView {
1204
1465
  if (options.markdownExtensions) {
1205
1466
  this.markdownExtensions = options.markdownExtensions;
1206
1467
  }
1468
+ if (options.frontMatter) {
1469
+ this.frontMatterOptions = options.frontMatter;
1470
+ }
1207
1471
  if (options.entryRenderers) {
1208
1472
  this.entryRenderers = options.entryRenderers;
1209
1473
  }
1210
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1474
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
1211
1475
  this.resetPreviewState();
1212
1476
  this.teardownEntryRenderer();
1213
1477
  this.entryMatchMissKey = null;
1214
1478
  this.render();
1215
1479
  }
1480
+ /**
1481
+ * The extensions actually handed to `MdzipRenderingService`: the front
1482
+ * matter extension first (always on — see `frontMatterOptions`), then
1483
+ * whatever extensions the host passed via `markdownExtensions`. A fresh
1484
+ * front matter extension instance is created each time this runs (view
1485
+ * construction, `setRenderingOptions`), which is fine — its per-render
1486
+ * state lives in a `WeakMap` keyed by the render context, not on the
1487
+ * extension instance across renders.
1488
+ */
1489
+ pipelineMarkdownExtensions() {
1490
+ return [mdzipFrontMatterExtension(this.frontMatterOptions), ...this.markdownExtensions];
1491
+ }
1492
+ /**
1493
+ * Chunking options combining every registered extension's
1494
+ * {@link MdzipMarkdownRenderExtension.shouldIsolateChunk} — see that doc
1495
+ * comment for why a token an extension owns needs its own chunk regardless
1496
+ * of the size budget — with the same treatment built in for any token that
1497
+ * embeds an image ({@link tokenEmbedsImage}'s doc comment), plus a forced
1498
+ * chunk boundary at every heading ({@link tokenIsHeading}'s doc comment)
1499
+ * so unrelated sections never share a chunk.
1500
+ */
1501
+ chunkOptions() {
1502
+ const extensions = this.pipelineMarkdownExtensions();
1503
+ return {
1504
+ shouldIsolate: (token) => tokenEmbedsImage(token) || extensions.some((ext) => ext.shouldIsolateChunk?.(token)),
1505
+ shouldStartChunk: tokenIsHeading
1506
+ };
1507
+ }
1216
1508
  /**
1217
1509
  * Replaces the view control policy without recreating the workspace or
1218
1510
  * CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
@@ -1290,6 +1582,9 @@ export class MdzipWorkspaceView {
1290
1582
  this.options.imageInsertMode = options.imageInsertMode;
1291
1583
  this.options.imageInsertHandler = options.imageInsertHandler;
1292
1584
  }
1585
+ setImageEditOptions(options) {
1586
+ this.options.imageEditHandler = options.imageEditHandler;
1587
+ }
1293
1588
  applyDensityClasses() {
1294
1589
  this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
1295
1590
  this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
@@ -1328,8 +1623,45 @@ export class MdzipWorkspaceView {
1328
1623
  this.options.onFailed?.(error);
1329
1624
  }
1330
1625
  }
1626
+ // Per-chunk handles live on their own records (see MdzipChunkRecord), not
1627
+ // in the flat array above — a full reset must still destroy every one of
1628
+ // them, the same as it always destroyed everything in previewHandles.
1629
+ for (const record of this.chunkedRenderState?.records ?? []) {
1630
+ for (const handle of record.handles) {
1631
+ try {
1632
+ handle.destroy();
1633
+ }
1634
+ catch (error) {
1635
+ this.options.onFailed?.(error);
1636
+ }
1637
+ }
1638
+ }
1639
+ // A new render generation invalidates any in-progress chunk draining —
1640
+ // Copy All's own abort check unwinds it and hides the dialog. It also
1641
+ // invalidates a "ready to copy" or "done" dialog left over from a
1642
+ // previous document: `copyReadyState.perform` closes over that
1643
+ // document's already-built HTML/text, and clicking Copy against a
1644
+ // now-superseded document would silently copy the wrong content.
1645
+ this.chunkedRenderState = null;
1646
+ this.chunkHtmlCache = null;
1647
+ this.copyRenderDialogState?.abort.abort();
1648
+ this.copyReadyState = null;
1649
+ this.copyRenderDoneState = null;
1650
+ }
1651
+ /** True when `this.previewMemo` — and by extension `this.previewGeneration` — still represents `snapshot`: nothing that feeds the preview (path, pathType, text, colorScheme) has changed since it was last rendered. */
1652
+ previewMemoMatchesSnapshot(snapshot) {
1653
+ const memo = this.previewMemo;
1654
+ return !!memo
1655
+ && memo.path === snapshot.currentPath
1656
+ && memo.pathType === snapshot.currentPathType
1657
+ && memo.text === snapshot.currentText
1658
+ && memo.colorScheme === this.colorScheme;
1331
1659
  }
1332
1660
  updatePreview(snapshot, entryClaimed) {
1661
+ this.lastContentChangeTime = performance.now();
1662
+ // Captured before anything below can move it — see
1663
+ // `pendingPreviewScrollRestore`'s doc comment.
1664
+ const previewScrollTopBeforeUpdate = this.elPreviewPane.scrollTop;
1333
1665
  if (entryClaimed) {
1334
1666
  // The entry renderer owns the pane stack; release preview resources so
1335
1667
  // a later fallback re-renders from scratch.
@@ -1343,20 +1675,39 @@ export class MdzipWorkspaceView {
1343
1675
  return;
1344
1676
  }
1345
1677
  const memo = this.previewMemo;
1346
- if (memo
1347
- && memo.path === snapshot.currentPath
1348
- && memo.pathType === snapshot.currentPathType
1349
- && memo.text === snapshot.currentText
1350
- && memo.colorScheme === this.colorScheme) {
1678
+ if (this.previewMemoMatchesSnapshot(snapshot)) {
1351
1679
  // Nothing that feeds the preview changed; keep the existing DOM and any
1352
1680
  // mounted extension handles.
1353
1681
  return;
1354
1682
  }
1355
1683
  const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
1684
+ // A same-document, text-only edit with an existing chunked structure can
1685
+ // be reconciled against (only the chunks whose source actually changed
1686
+ // get touched) instead of torn down and rebuilt from scratch. Every
1687
+ // other case — first render, a path/pathType/colorScheme change, a
1688
+ // non-default renderer, a prior explicit reset that already cleared
1689
+ // chunkedRenderState, or an edit-triggered batch mount still in flight
1690
+ // for the current generation (`mounting` — see its doc comment: its
1691
+ // `cursor` doesn't yet reflect where that mount actually is, so
1692
+ // reconciling against it risks orphaned duplicate DOM) — falls through
1693
+ // to today's full reset + cold start, unchanged.
1694
+ const reconcileEligible = !!memo
1695
+ && memo.path === snapshot.currentPath
1696
+ && memo.pathType === snapshot.currentPathType
1697
+ && memo.colorScheme === this.colorScheme
1698
+ && snapshot.currentPathType === 'markdown'
1699
+ && this.renderingService.supportsChunking
1700
+ && !!this.chunkedRenderState
1701
+ && this.chunkedRenderState.generation === this.previewGeneration
1702
+ && !this.chunkedRenderState.mounting;
1703
+ const priorChunkedState = reconcileEligible ? this.chunkedRenderState : null;
1356
1704
  this.previewAbort?.abort();
1357
1705
  this.previewAbort = null;
1358
- this.destroyPreviewHandles();
1706
+ if (!priorChunkedState) {
1707
+ this.destroyPreviewHandles();
1708
+ }
1359
1709
  const generation = ++this.previewGeneration;
1710
+ this.pendingPreviewScrollRestore = { scrollTop: previewScrollTopBeforeUpdate, generation };
1360
1711
  this.previewHydrated = false;
1361
1712
  this.previewMemo = {
1362
1713
  path: snapshot.currentPath,
@@ -1388,6 +1739,15 @@ export class MdzipWorkspaceView {
1388
1739
  const abort = new AbortController();
1389
1740
  this.previewAbort = abort;
1390
1741
  const context = this.createMarkdownContext(snapshot, abort.signal);
1742
+ if (this.renderingService.supportsChunking) {
1743
+ if (priorChunkedState) {
1744
+ this.reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorChunkedState);
1745
+ }
1746
+ else {
1747
+ this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1748
+ }
1749
+ return;
1750
+ }
1391
1751
  let result;
1392
1752
  try {
1393
1753
  result = this.renderingService.renderMarkdown(snapshot.currentText, context);
@@ -1459,28 +1819,113 @@ export class MdzipWorkspaceView {
1459
1819
  */
1460
1820
  mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
1461
1821
  this.elPreviewContent.innerHTML = html;
1462
- const session = this.assetSession;
1463
- const document = this.elPreviewContent.ownerDocument;
1822
+ // Cheap pass over every <img> happens before extensions/code-block
1823
+ // controls mount, same relative order as always — see
1824
+ // collectPendingImages for why it's split from the (expensive) slot
1825
+ // creation that follows.
1826
+ const pending = this.collectPendingImages([this.elPreviewContent], context, generation, animateImageHydration);
1827
+ this.mountPreviewExtensions(context, generation);
1828
+ const codeBlockHandle = this.mountCodeBlockControls();
1829
+ if (codeBlockHandle) {
1830
+ this.previewHandles.push(codeBlockHandle);
1831
+ }
1832
+ this.firePreviewRendered(snapshot, generation);
1833
+ this.hydrateImages(pending, context, generation, animateImageHydration, () => {
1834
+ this.fireAssetsHydrated(snapshot, generation);
1835
+ });
1836
+ }
1837
+ /**
1838
+ * Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
1839
+ * mutation. Archive-relative sources have their `src` stripped here
1840
+ * (immediately, so the browser never fires a bad network request for the
1841
+ * archive-relative path) and are returned for {@link hydrateImages};
1842
+ * external/data/fragment sources are left untouched (just get their align
1843
+ * class, if any — they never get a slot). Returns `[]` without touching
1844
+ * anything when there's no asset session to resolve archive images
1845
+ * against, matching `mountPreviewHtml`'s no-op image handling.
1846
+ *
1847
+ * An image this session has *already* resolved once (e.g. it was already
1848
+ * on screen before an edit elsewhere caused its chunk to remount) is
1849
+ * applied immediately here via `resolveKnownImage` — no slot, no loading
1850
+ * class, no `hydrateImages`/`IntersectionObserver` round trip — instead of
1851
+ * being handed to the async placeholder-then-swap path meant for an image
1852
+ * that's genuinely being resolved for the first time. Skipping that path
1853
+ * for a re-mount of an already-known image is what actually avoids the
1854
+ * visible flash; still going through it (even instantly) briefly blanks
1855
+ * the image, since a fresh `<img>` element never carries over its
1856
+ * predecessor's already-resolved `src`.
1857
+ */
1858
+ collectPendingImages(roots, context, generation, animateImageHydration) {
1859
+ if (!this.assetSession) {
1860
+ return [];
1861
+ }
1464
1862
  const pending = [];
1465
- for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
1466
- applyRawHtmlImageSizeAttributes(image);
1467
- const source = image.getAttribute('src');
1468
- // Leave external, protocol-relative, data, and fragment URLs untouched.
1469
- if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1470
- const alignClass = rawHtmlImageAlignClass(image);
1471
- if (alignClass) {
1472
- image.classList.add(alignClass);
1863
+ for (const root of roots) {
1864
+ for (const image of Array.from(root.querySelectorAll('img'))) {
1865
+ applyRawHtmlImageSizeAttributes(image);
1866
+ const source = image.getAttribute('src');
1867
+ // Leave external, protocol-relative, data, and fragment URLs untouched.
1868
+ if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1869
+ const alignClass = rawHtmlImageAlignClass(image);
1870
+ if (alignClass) {
1871
+ image.classList.add(alignClass);
1872
+ }
1873
+ continue;
1473
1874
  }
1474
- continue;
1875
+ const known = this.assetSession.resolveKnownImage(source, context.currentPath);
1876
+ if (known) {
1877
+ if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
1878
+ image.setAttribute('width', String(known.width));
1879
+ image.setAttribute('height', String(known.height));
1880
+ }
1881
+ // No slot wrapper on this path — same direct-on-image alignment as
1882
+ // the external-URL branch above, not the slot's own align classes.
1883
+ const alignClass = rawHtmlImageAlignClass(image);
1884
+ if (alignClass) {
1885
+ image.classList.add(alignClass);
1886
+ }
1887
+ this.attachImageLoadHandlers(image, source, known.url, context, generation);
1888
+ image.setAttribute('src', known.url);
1889
+ continue;
1890
+ }
1891
+ image.removeAttribute('src');
1892
+ if (animateImageHydration) {
1893
+ image.classList.add('mdzip-image-loading');
1894
+ }
1895
+ pending.push({ image, source });
1475
1896
  }
1476
- // Drop the archive-relative src so the browser does not fetch the bad
1477
- // path. Wrap the image in a collapsed slot so the text stays compact and
1478
- // immediately readable; the slot eases open to the reserved height once
1479
- // the image resolves.
1480
- image.removeAttribute('src');
1481
- if (animateImageHydration) {
1482
- image.classList.add('mdzip-image-loading');
1897
+ }
1898
+ return pending;
1899
+ }
1900
+ /**
1901
+ * Slot-wraps and resolves `pending` images (from {@link collectPendingImages}),
1902
+ * only decompressing/resolving ones near the viewport up front — a
1903
+ * document with a few hundred distinct embedded images would otherwise
1904
+ * decompress and blob-URL every one of them synchronously, freezing the UI
1905
+ * for as long as that takes. Falls back to eager resolution when
1906
+ * IntersectionObserver isn't available (e.g. non-browser hosts). Calls
1907
+ * `onSettled` once every image in `pending` has resolved (or immediately
1908
+ * if `pending` is empty) — callers that want a single "hydrated" signal
1909
+ * for a whole preview pass `pending` from every root at once; callers
1910
+ * mounting one chunk among several within the same generation (see
1911
+ * {@link mountChunkBatch}) pass a no-op instead, so the signal only fires
1912
+ * once, for the batch it's semantically tied to.
1913
+ */
1914
+ hydrateImages(pending, context, generation, animateImageHydration, onSettled) {
1915
+ const session = this.assetSession;
1916
+ if (!session || pending.length === 0) {
1917
+ onSettled();
1918
+ return;
1919
+ }
1920
+ const document = this.elPreviewContent.ownerDocument;
1921
+ let remaining = pending.length;
1922
+ const settle = () => {
1923
+ remaining -= 1;
1924
+ if (remaining === 0) {
1925
+ onSettled();
1483
1926
  }
1927
+ };
1928
+ const createImageSlot = (image) => {
1484
1929
  const slot = document.createElement('span');
1485
1930
  slot.className = animateImageHydration
1486
1931
  ? 'mdzip-image-slot'
@@ -1494,26 +1939,9 @@ export class MdzipWorkspaceView {
1494
1939
  }
1495
1940
  image.parentNode?.insertBefore(slot, image);
1496
1941
  slot.appendChild(image);
1497
- pending.push({ image, slot, source });
1498
- }
1499
- this.mountPreviewExtensions(context, generation);
1500
- const codeBlockHandle = this.mountCodeBlockControls();
1501
- if (codeBlockHandle) {
1502
- this.previewHandles.push(codeBlockHandle);
1503
- }
1504
- this.firePreviewRendered(snapshot, generation);
1505
- if (!session || pending.length === 0) {
1506
- this.fireAssetsHydrated(snapshot, generation);
1507
- return;
1508
- }
1509
- let remaining = pending.length;
1510
- const settle = () => {
1511
- remaining -= 1;
1512
- if (remaining === 0) {
1513
- this.fireAssetsHydrated(snapshot, generation);
1514
- }
1942
+ return slot;
1515
1943
  };
1516
- for (const { image, slot, source } of pending) {
1944
+ const hydrate = (image, slot, source) => {
1517
1945
  void session.resolveImage(source, context.currentPath).then((resolved) => {
1518
1946
  if (generation !== this.previewGeneration || context.signal.aborted) {
1519
1947
  settle();
@@ -1545,6 +1973,593 @@ export class MdzipWorkspaceView {
1545
1973
  this.openImageSlot(slot);
1546
1974
  settle();
1547
1975
  });
1976
+ };
1977
+ const observerWindow = document.defaultView;
1978
+ if (!observerWindow?.IntersectionObserver) {
1979
+ for (const { image, source } of pending) {
1980
+ hydrate(image, createImageSlot(image), source);
1981
+ }
1982
+ return;
1983
+ }
1984
+ const bySlot = new Map();
1985
+ const observer = new observerWindow.IntersectionObserver((entries) => {
1986
+ for (const entry of entries) {
1987
+ if (!entry.isIntersecting)
1988
+ continue;
1989
+ observer.unobserve(entry.target);
1990
+ const item = bySlot.get(entry.target);
1991
+ if (item)
1992
+ hydrate(item.image, entry.target, item.source);
1993
+ }
1994
+ }, { root: this.elPreviewPane, rootMargin: '600px 0px' });
1995
+ this.previewHandles.push({ destroy: () => observer.disconnect() });
1996
+ // Slot creation + observer registration is a real DOM tree mutation per
1997
+ // image (as opposed to the cheap attribute-only pass in
1998
+ // collectPendingImages), and at thousands of occurrences (e.g. a chat
1999
+ // export where a handful of distinct avatars repeat across every row)
2000
+ // doing it all synchronously blocks the main thread for seconds.
2001
+ // Time-budget it across animation frames instead — the first chunk
2002
+ // still runs synchronously as part of this call (no yield before it),
2003
+ // so small documents (every existing test) see their slots exist
2004
+ // immediately, same as before.
2005
+ const CHUNK_BUDGET_MS = 8;
2006
+ let cursor = 0;
2007
+ const processChunk = () => {
2008
+ if (generation !== this.previewGeneration || context.signal.aborted)
2009
+ return;
2010
+ const chunkStart = observerWindow.performance.now();
2011
+ while (cursor < pending.length && observerWindow.performance.now() - chunkStart < CHUNK_BUDGET_MS) {
2012
+ const item = pending[cursor];
2013
+ cursor += 1;
2014
+ const slot = createImageSlot(item.image);
2015
+ bySlot.set(slot, item);
2016
+ observer.observe(slot);
2017
+ }
2018
+ if (cursor < pending.length) {
2019
+ observerWindow.requestAnimationFrame(processChunk);
2020
+ }
2021
+ };
2022
+ processChunk();
2023
+ }
2024
+ /**
2025
+ * Opt-in (`progressiveTextRendering`) alternative to the whole-document
2026
+ * `renderMarkdown()` path above: tokenizes the markdown once, then renders
2027
+ * and mounts it in chunks near the viewport instead of all at once — see
2028
+ * {@link mountChunkedPreview}. Only reachable when
2029
+ * `renderingService.supportsChunking` is true (the default marked-based
2030
+ * renderer); the caller already checked that.
2031
+ */
2032
+ renderChunkedPreview(snapshot, context, generation, animateImageHydration) {
2033
+ let tokens;
2034
+ try {
2035
+ tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
2036
+ }
2037
+ catch (error) {
2038
+ this.options.onFailed?.(error);
2039
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
2040
+ this.firePreviewRendered(snapshot, generation);
2041
+ this.fireAssetsHydrated(snapshot, generation);
2042
+ return;
2043
+ }
2044
+ if (Array.isArray(tokens)) {
2045
+ this.mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration);
2046
+ return;
2047
+ }
2048
+ void tokens.then((resolved) => {
2049
+ if (generation !== this.previewGeneration || context.signal.aborted)
2050
+ return;
2051
+ this.mountChunkedPreview(resolved, snapshot, context, generation, animateImageHydration);
2052
+ }).catch((error) => {
2053
+ if (generation !== this.previewGeneration || context.signal.aborted)
2054
+ return;
2055
+ if (error?.name !== 'AbortError') {
2056
+ this.options.onFailed?.(error);
2057
+ }
2058
+ });
2059
+ }
2060
+ /**
2061
+ * Same-document, text-only-edit counterpart to {@link renderChunkedPreview}:
2062
+ * tokenizes the new text, then reconciles against the *previous* render's
2063
+ * records (`priorState`) instead of tearing everything down. Only reachable
2064
+ * when `updatePreview` determined the prior chunked structure is still
2065
+ * reuse-eligible (same path/pathType/colorScheme); the caller already
2066
+ * checked that.
2067
+ */
2068
+ reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorState) {
2069
+ let tokens;
2070
+ try {
2071
+ tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
2072
+ }
2073
+ catch (error) {
2074
+ this.options.onFailed?.(error);
2075
+ // Can't reconcile without tokens — fall back to a hard reset.
2076
+ this.destroyPreviewHandles();
2077
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
2078
+ this.firePreviewRendered(snapshot, generation);
2079
+ this.fireAssetsHydrated(snapshot, generation);
2080
+ return;
2081
+ }
2082
+ const proceed = (resolvedTokens) => {
2083
+ if (generation !== this.previewGeneration || context.signal.aborted)
2084
+ return;
2085
+ this.applyChunkReconciliation(resolvedTokens, snapshot, context, generation, animateImageHydration, priorState);
2086
+ };
2087
+ if (Array.isArray(tokens)) {
2088
+ proceed(tokens);
2089
+ return;
2090
+ }
2091
+ void tokens.then(proceed).catch((error) => {
2092
+ if (generation !== this.previewGeneration || context.signal.aborted)
2093
+ return;
2094
+ if (error?.name !== 'AbortError') {
2095
+ this.options.onFailed?.(error);
2096
+ }
2097
+ });
2098
+ }
2099
+ /** Destroys one chunk record's mounted DOM and handles (but not the record object itself). */
2100
+ teardownChunkRecord(record) {
2101
+ for (const handle of record.handles) {
2102
+ try {
2103
+ handle.destroy();
2104
+ }
2105
+ catch (error) {
2106
+ this.options.onFailed?.(error);
2107
+ }
2108
+ }
2109
+ record.root?.remove();
2110
+ }
2111
+ /**
2112
+ * Diffs the new tokenize pass's chunks against `priorState.records` by
2113
+ * source-text identity (`chunkSourceKey`) and reconciles: chunks in the
2114
+ * matched leading/trailing run keep their existing DOM and handles
2115
+ * untouched (never re-rendered, never re-mounted — see
2116
+ * `chunkSourceKey`'s doc comment for why that matters for extensions like
2117
+ * mermaid); only the "middle" range that actually changed is torn down and
2118
+ * remounted. Uses prefix/suffix matching rather than a general LCS — a
2119
+ * text editor's edits are localized, so this is both simpler and correct
2120
+ * for the real-world case.
2121
+ */
2122
+ applyChunkReconciliation(tokens, snapshot, context, generation, animateImageHydration, priorState) {
2123
+ // Tear down any live sentinel from the prior state before touching DOM —
2124
+ // same reasoning as drainRemainingChunks: stops it from racing this
2125
+ // reconciliation and double-mounting a chunk.
2126
+ priorState.sentinelHandle?.destroy();
2127
+ this.previewHandles = this.previewHandles.filter((h) => h !== priorState.sentinelHandle);
2128
+ const newChunks = groupTokensIntoChunks(tokens, this.chunkOptions());
2129
+ const oldRecords = priorState.records;
2130
+ const oldCursor = priorState.cursor;
2131
+ if (newChunks.length === 0) {
2132
+ // Whole document collapsed to nothing renderable — nothing to reuse.
2133
+ for (const record of oldRecords) {
2134
+ this.teardownChunkRecord(record);
2135
+ }
2136
+ this.elPreviewContent.replaceChildren();
2137
+ this.chunkedRenderState = null;
2138
+ this.firePreviewRendered(snapshot, generation);
2139
+ this.fireAssetsHydrated(snapshot, generation);
2140
+ return;
2141
+ }
2142
+ const oldKeys = oldRecords.map((r) => r.key);
2143
+ const newKeys = newChunks.map((c) => chunkSourceKey(c));
2144
+ const maxMatch = Math.min(oldKeys.length, newKeys.length);
2145
+ let prefixLen = 0;
2146
+ while (prefixLen < maxMatch && oldKeys[prefixLen] === newKeys[prefixLen])
2147
+ prefixLen += 1;
2148
+ const maxSuffix = maxMatch - prefixLen;
2149
+ let suffixLen = 0;
2150
+ while (suffixLen < maxSuffix
2151
+ && oldKeys[oldKeys.length - 1 - suffixLen] === newKeys[newKeys.length - 1 - suffixLen])
2152
+ suffixLen += 1;
2153
+ const oldMiddleStart = prefixLen;
2154
+ const oldMiddleEnd = oldRecords.length - suffixLen;
2155
+ const newMiddleStart = prefixLen;
2156
+ const newMiddleEnd = newChunks.length - suffixLen;
2157
+ const newRecords = [];
2158
+ for (let i = 0; i < prefixLen; i += 1) {
2159
+ newRecords.push({ ...oldRecords[i], key: newKeys[i], tokens: newChunks[i] });
2160
+ }
2161
+ for (let i = newMiddleStart; i < newMiddleEnd; i += 1) {
2162
+ newRecords.push({ key: newKeys[i], tokens: newChunks[i], root: null, html: null, handles: [] });
2163
+ }
2164
+ for (let i = 0; i < suffixLen; i += 1) {
2165
+ const oi = oldRecords.length - suffixLen + i;
2166
+ const ni = newChunks.length - suffixLen + i;
2167
+ newRecords.push({ ...oldRecords[oi], key: newKeys[ni], tokens: newChunks[ni] });
2168
+ }
2169
+ // Destroy DOM+handles for old-middle records that were actually mounted
2170
+ // (bounded by the old cursor — everything from there on was still
2171
+ // waiting in the lazy-load tail, never mounted, nothing to destroy).
2172
+ const oldMountedMiddleEnd = Math.min(oldCursor, oldMiddleEnd);
2173
+ for (let i = oldMiddleStart; i < oldMountedMiddleEnd; i += 1) {
2174
+ this.teardownChunkRecord(oldRecords[i]);
2175
+ }
2176
+ const oldMountedMiddleCount = Math.max(0, oldMountedMiddleEnd - oldMiddleStart);
2177
+ // Reference node for splicing in the new middle's DOM: the first
2178
+ // surviving *mounted* suffix record (mounted suffix records are
2179
+ // contiguous from newMiddleEnd by construction), else null (append).
2180
+ let insertBeforeNode = null;
2181
+ for (let i = newMiddleEnd; i < newRecords.length; i += 1) {
2182
+ if (newRecords[i].root) {
2183
+ insertBeforeNode = newRecords[i].root;
2184
+ break;
2185
+ }
2186
+ }
2187
+ this.chunkedRenderState = {
2188
+ records: newRecords,
2189
+ cursor: oldCursor,
2190
+ context,
2191
+ generation,
2192
+ animateImageHydration,
2193
+ sentinelHandle: null,
2194
+ mounting: oldMountedMiddleCount > 0
2195
+ };
2196
+ if (oldMountedMiddleCount === 0) {
2197
+ // The edit is entirely beyond what was mounted (progressive mode,
2198
+ // still in the lazy-load tail) — nothing was destroyed, nothing needs
2199
+ // mounting now. Cursor is unaffected: every index below it is an
2200
+ // untouched prefix survivor, valid at the same index in newRecords. The
2201
+ // old sentinel was already torn down above, so a fresh one has to be
2202
+ // armed to keep the lazy-load tail continuing.
2203
+ if (oldCursor < newRecords.length) {
2204
+ this.armChunkSentinel(newRecords, oldCursor, context, generation, animateImageHydration);
2205
+ }
2206
+ this.firePreviewRendered(snapshot, generation);
2207
+ this.fireAssetsHydrated(snapshot, generation);
2208
+ return;
2209
+ }
2210
+ void this.mountReconciledMiddle(newRecords, newMiddleStart, newMiddleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd);
2211
+ }
2212
+ /**
2213
+ * Mounts a reconciliation's replacement "middle" range in full (uncapped —
2214
+ * a single edit only ever touches 1-2 chunks in practice; see the plan's
2215
+ * disclosed v1 risk for the pathological large-paste case), then carries
2216
+ * forward however much of the surviving suffix was already mounted before
2217
+ * reconciling, and re-arms the sentinel for whatever's still left.
2218
+ */
2219
+ async mountReconciledMiddle(records, middleStart, middleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd) {
2220
+ let cursor = middleStart;
2221
+ let mountedRoots = [];
2222
+ while (cursor < middleEnd) {
2223
+ const batch = await this.renderAndMountChunkBatch(records, cursor, context, generation, insertBeforeNode);
2224
+ if (generation !== this.previewGeneration || context.signal.aborted)
2225
+ return;
2226
+ if (batch.cursor === cursor)
2227
+ break; // Aborted/superseded mid-loop with no progress.
2228
+ cursor = batch.cursor;
2229
+ mountedRoots = mountedRoots.concat(batch.mountedRoots);
2230
+ }
2231
+ const carriedSuffixMounted = Math.max(0, oldCursor - oldMiddleEnd);
2232
+ const finalCursor = Math.min(records.length, Math.max(cursor, middleEnd) + carriedSuffixMounted);
2233
+ // See armChunkSentinel's mountNext for why this is recorded before the
2234
+ // staleness check: finalCursor already correctly folds in the carried-
2235
+ // forward suffix (chunks beyond the reconciled middle that were already
2236
+ // mounted), so discarding it here just because a newer edit has since
2237
+ // landed would regress chunkedRenderState.cursor back down — causing the
2238
+ // next reconcile to re-arm a sentinel from that stale, lower cursor and
2239
+ // re-mount (duplicate) chunks that are already correctly in the DOM.
2240
+ this.recordChunkProgress(generation, records, finalCursor);
2241
+ if (generation !== this.previewGeneration || context.signal.aborted)
2242
+ return;
2243
+ if (this.chunkedRenderState?.generation === generation) {
2244
+ this.chunkedRenderState.mounting = false;
2245
+ }
2246
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2247
+ this.hydrateImages(pending, context, generation, animateImageHydration, () => {
2248
+ this.fireAssetsHydrated(snapshot, generation);
2249
+ });
2250
+ this.firePreviewRendered(snapshot, generation);
2251
+ if (this.chunkedRenderState?.generation === generation && finalCursor < records.length) {
2252
+ this.armChunkSentinel(records, finalCursor, context, generation, animateImageHydration);
2253
+ }
2254
+ }
2255
+ /**
2256
+ * Groups tokens into chunks and mounts them: an initial batch synchronously
2257
+ * (enough for a small document to behave exactly like the non-chunked
2258
+ * path), then the rest as the user scrolls near a trailing sentinel
2259
+ * element, via the same `IntersectionObserver` + time-budgeted-rAF pattern
2260
+ * already used for image hydration — just for "need more text" instead of
2261
+ * "need this image". `onAssetsHydrated` fires once for the initial batch's
2262
+ * images only (not re-fired per later chunk) — `onPreviewRendered` still
2263
+ * means "the initial batch is in the DOM", same intent as the non-chunked
2264
+ * path, just proportional to the viewport instead of the whole document.
2265
+ */
2266
+ mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration) {
2267
+ this.elPreviewContent.replaceChildren();
2268
+ // Also called with no options in renderFullDocumentHtml — see the
2269
+ // comment there; keep both call sites' chunking in sync.
2270
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
2271
+ if (chunks.length === 0) {
2272
+ this.chunkedRenderState = null;
2273
+ this.firePreviewRendered(snapshot, generation);
2274
+ this.fireAssetsHydrated(snapshot, generation);
2275
+ return;
2276
+ }
2277
+ const records = chunks.map((chunkTokens) => ({
2278
+ key: chunkSourceKey(chunkTokens),
2279
+ tokens: chunkTokens,
2280
+ root: null,
2281
+ html: null,
2282
+ handles: []
2283
+ }));
2284
+ this.chunkedRenderState = {
2285
+ records, cursor: 0, context, generation, animateImageHydration, sentinelHandle: null,
2286
+ // Set for the whole initial batch/eager mount below — see the field's
2287
+ // doc comment: an edit landing before this settles must not reconcile
2288
+ // against a `cursor` that doesn't yet reflect this in-flight mount.
2289
+ mounting: true
2290
+ };
2291
+ if (this.progressiveTextRendering) {
2292
+ // Mount an initial batch synchronously (enough for a small document to
2293
+ // behave exactly like a non-progressive mount), then lazily continue
2294
+ // the rest as the user scrolls near a trailing sentinel.
2295
+ void this.mountChunkBatch(records, 0, context, generation, animateImageHydration, () => {
2296
+ this.fireAssetsHydrated(snapshot, generation);
2297
+ }).then((cursor) => {
2298
+ // See armChunkSentinel's mountNext for why this is recorded before
2299
+ // the staleness check below.
2300
+ this.recordChunkProgress(generation, records, cursor);
2301
+ if (generation !== this.previewGeneration || context.signal.aborted)
2302
+ return;
2303
+ if (this.chunkedRenderState?.generation === generation) {
2304
+ this.chunkedRenderState.mounting = false;
2305
+ }
2306
+ this.firePreviewRendered(snapshot, generation);
2307
+ if (cursor < records.length) {
2308
+ this.armChunkSentinel(records, cursor, context, generation, animateImageHydration);
2309
+ }
2310
+ });
2311
+ return;
2312
+ }
2313
+ // Not opted into lazy-load-on-scroll: mount every chunk up front, same
2314
+ // visible end state as the old monolithic non-chunked path, but yielding
2315
+ // a frame between batches (mirroring drainRemainingChunks) instead of
2316
+ // blocking the main thread for one giant synchronous render — see the
2317
+ // plan's disclosed cold-start tradeoff.
2318
+ void this.mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot);
2319
+ }
2320
+ /**
2321
+ * Mounts every chunk of a non-progressive (`progressiveTextRendering:
2322
+ * false`) document up front — same end state as the old monolithic
2323
+ * non-chunked path (the whole document mounted before `onPreviewRendered`
2324
+ * fires), but yielding a frame between batches (mirroring
2325
+ * `drainRemainingChunks`) instead of blocking the main thread for one
2326
+ * giant synchronous render.
2327
+ */
2328
+ async mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot) {
2329
+ const allPending = [];
2330
+ while (generation === this.previewGeneration && !context.signal.aborted) {
2331
+ const cursorBefore = this.chunkedRenderState?.generation === generation
2332
+ ? this.chunkedRenderState.cursor
2333
+ : records.length;
2334
+ if (cursorBefore >= records.length)
2335
+ break;
2336
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursorBefore, context, generation);
2337
+ // See armChunkSentinel's mountNext for why this is recorded before the
2338
+ // staleness check: the chunks this batch appended are physically in
2339
+ // the DOM regardless of whether a newer edit has since landed, and
2340
+ // recordChunkProgress's own generation check keeps this a safe no-op
2341
+ // once chunkedRenderState has moved on.
2342
+ this.recordChunkProgress(generation, records, cursor);
2343
+ if (generation !== this.previewGeneration || context.signal.aborted)
2344
+ return;
2345
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
2346
+ if (cursor >= records.length)
2347
+ break;
2348
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
2349
+ }
2350
+ if (generation === this.previewGeneration && !context.signal.aborted) {
2351
+ if (this.chunkedRenderState?.generation === generation) {
2352
+ this.chunkedRenderState.mounting = false;
2353
+ }
2354
+ this.firePreviewRendered(snapshot, generation);
2355
+ this.hydrateImages(allPending, context, generation, animateImageHydration, () => {
2356
+ this.fireAssetsHydrated(snapshot, generation);
2357
+ });
2358
+ }
2359
+ }
2360
+ /**
2361
+ * Updates `chunkedRenderState.cursor` after a batch mounts. A no-op if a
2362
+ * newer render generation has already superseded this one. Deliberately
2363
+ * never nulls `chunkedRenderState` out just because `cursor` reached
2364
+ * `records.length` — the records stay alive for the whole generation so a
2365
+ * later same-document edit can reconcile against them (see
2366
+ * `applyChunkReconciliation`). Callers that need "is there still an
2367
+ * unmounted lazy tail" (Copy All's instant path, the scroll-to-bottom fast
2368
+ * path) check `cursor < records.length` directly instead of this field's
2369
+ * truthiness.
2370
+ */
2371
+ recordChunkProgress(generation, records, cursor) {
2372
+ if (this.chunkedRenderState?.generation !== generation)
2373
+ return;
2374
+ this.chunkedRenderState.cursor = cursor;
2375
+ }
2376
+ /**
2377
+ * Renders+appends chunks starting at `startCursor` up to a char budget
2378
+ * (enough for one screenful, roughly) *or* a wall-clock time budget,
2379
+ * whichever comes first — char count alone is a poor proxy for cost on a
2380
+ * document where some chunks are plain text and others are dense with
2381
+ * `<img>` tags (parsing + sanitizing + inserting into an already-huge
2382
+ * `elPreviewContent` gets measurably more expensive per chunk as an
2383
+ * image-heavy document's accumulated DOM grows); the time budget catches
2384
+ * what the char budget alone misses. Mounts extensions/code-block controls
2385
+ * for exactly the chunks it appended (never re-scanning earlier ones), and
2386
+ * returns the cursor to resume from plus those chunks' roots (for the
2387
+ * caller to run `collectPendingImages` on). Shared by every caller that
2388
+ * mounts chunk batches — the initial batch, every later sentinel-triggered
2389
+ * continuation, and Copy All's drain — image hydration itself is *not*
2390
+ * included here; see {@link mountChunkBatch} and
2391
+ * {@link drainRemainingChunks} for the two different ways callers pace it.
2392
+ */
2393
+ async renderAndMountChunkBatch(records, startCursor, context, generation, insertBeforeNode = null) {
2394
+ const BATCH_CHAR_BUDGET = 4000;
2395
+ const BATCH_TIME_BUDGET_MS = 10;
2396
+ const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
2397
+ const batchStart = clock.now();
2398
+ let cursor = startCursor;
2399
+ let renderedChars = 0;
2400
+ const mountedRoots = [];
2401
+ const batchStartCursor = startCursor;
2402
+ while (cursor < records.length
2403
+ && renderedChars < BATCH_CHAR_BUDGET
2404
+ && (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
2405
+ if (generation !== this.previewGeneration || context.signal.aborted)
2406
+ return { cursor, mountedRoots };
2407
+ const chunkTokens = records[cursor].tokens;
2408
+ const chunkIndex = cursor;
2409
+ cursor += 1;
2410
+ let html;
2411
+ let cacheHit;
2412
+ try {
2413
+ cacheHit = this.chunkHtmlCache?.generation === generation
2414
+ ? this.chunkHtmlCache.html.get(chunkIndex)
2415
+ : undefined;
2416
+ if (cacheHit !== undefined) {
2417
+ html = cacheHit;
2418
+ }
2419
+ else {
2420
+ const result = this.renderingService.renderChunk(chunkTokens, context);
2421
+ html = typeof result === 'string' ? result : await result;
2422
+ }
2423
+ }
2424
+ catch (error) {
2425
+ if (error?.name !== 'AbortError') {
2426
+ this.options.onFailed?.(error);
2427
+ }
2428
+ continue;
2429
+ }
2430
+ if (generation !== this.previewGeneration || context.signal.aborted)
2431
+ return { cursor, mountedRoots };
2432
+ if (cacheHit === undefined) {
2433
+ // groupTokensIntoChunks is also called with no options in
2434
+ // renderFullDocumentHtml — keep both call sites' chunking in sync,
2435
+ // since chunk index is this cache's only key.
2436
+ if (this.chunkHtmlCache?.generation !== generation) {
2437
+ this.chunkHtmlCache = { generation, html: new Map() };
2438
+ }
2439
+ this.chunkHtmlCache.html.set(chunkIndex, html);
2440
+ }
2441
+ const root = this.appendChunkHtml(html, insertBeforeNode);
2442
+ records[chunkIndex].root = root;
2443
+ records[chunkIndex].html = html;
2444
+ mountedRoots.push(root);
2445
+ renderedChars += html.length;
2446
+ }
2447
+ for (const record of records.slice(batchStartCursor, cursor)) {
2448
+ if (!record.root)
2449
+ continue;
2450
+ this.mountPreviewExtensions(context, generation, record.root, record.handles);
2451
+ const codeBlockHandle = this.mountCodeBlockControls(record.root);
2452
+ if (codeBlockHandle) {
2453
+ record.handles.push(codeBlockHandle);
2454
+ }
2455
+ }
2456
+ return { cursor, mountedRoots };
2457
+ }
2458
+ /**
2459
+ * `renderAndMountChunkBatch` plus its own immediate, independent
2460
+ * `hydrateImages` pass — the shape every caller except Copy All's drain
2461
+ * wants: mount a batch, then hydrate whatever images it contained,
2462
+ * decoupled from mounting the next batch. Scroll-paced callers (the
2463
+ * initial batch, every sentinel continuation) are naturally rate-limited
2464
+ * by how fast the user scrolls, so one independent hydration loop per
2465
+ * batch never has a chance to pile up against another.
2466
+ */
2467
+ async mountChunkBatch(records, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2468
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, startCursor, context, generation);
2469
+ // Same relative order as the non-chunked path: cheap image pass, then
2470
+ // the (expensive) slot/observe pass.
2471
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2472
+ this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
2473
+ return cursor;
2474
+ }
2475
+ /**
2476
+ * Wraps one chunk's rendered HTML in a mount boundary and appends it (or,
2477
+ * given `beforeNode`, splices it in ahead of that still-attached node —
2478
+ * used by reconciliation to insert a replacement chunk ahead of a
2479
+ * surviving mounted suffix). See the `.mdzip-chunk` CSS rules for why.
2480
+ */
2481
+ appendChunkHtml(html, beforeNode = null) {
2482
+ const doc = this.elPreviewContent.ownerDocument;
2483
+ const wrapper = doc.createElement('div');
2484
+ wrapper.className = 'mdzip-chunk';
2485
+ wrapper.innerHTML = html;
2486
+ if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
2487
+ this.elPreviewContent.insertBefore(wrapper, beforeNode);
2488
+ }
2489
+ else {
2490
+ this.elPreviewContent.appendChild(wrapper);
2491
+ }
2492
+ return wrapper;
2493
+ }
2494
+ /**
2495
+ * Appends a trailing sentinel and mounts the next chunk batch once it's
2496
+ * within `rootMargin` of the viewport — a larger margin than images'
2497
+ * (`hydrateImages`' 600px) since keeping text ahead of scroll is cheap
2498
+ * relative to keeping images ahead. Re-arms itself after each batch until
2499
+ * every chunk is mounted. Falls back to mounting everything immediately
2500
+ * when IntersectionObserver isn't available, matching `hydrateImages`.
2501
+ */
2502
+ armChunkSentinel(records, cursor, context, generation, animateImageHydration) {
2503
+ const doc = this.elPreviewContent.ownerDocument;
2504
+ const observerWindow = doc.defaultView;
2505
+ const mountNext = (nextCursor) => {
2506
+ // Same race this field guards against everywhere else: an edit landing
2507
+ // while this scroll-triggered continuation is mid-flight must not
2508
+ // reconcile against a cursor that doesn't yet reflect it.
2509
+ if (this.chunkedRenderState?.generation === generation) {
2510
+ this.chunkedRenderState.mounting = true;
2511
+ }
2512
+ void this.mountChunkBatch(records, nextCursor, context, generation, animateImageHydration, () => { })
2513
+ .then((newCursor) => {
2514
+ // Record whatever this batch actually mounted regardless of
2515
+ // whether a newer edit has since landed — recordChunkProgress's
2516
+ // own generation check already makes this a safe no-op once
2517
+ // chunkedRenderState has moved on. Skipping this call when stale
2518
+ // was a real bug: the chunks this batch appended are physically in
2519
+ // the DOM either way, but a newer edit's reconcile still saw the
2520
+ // *old*, un-advanced cursor and re-armed a sentinel from the same
2521
+ // starting point — re-rendering and re-appending (duplicating)
2522
+ // chunks that were already mounted, visible as an already-settled
2523
+ // image flashing back to broken as a fresh duplicate replaced it.
2524
+ this.recordChunkProgress(generation, records, newCursor);
2525
+ if (generation !== this.previewGeneration || context.signal.aborted)
2526
+ return;
2527
+ if (this.chunkedRenderState?.generation === generation) {
2528
+ this.chunkedRenderState.mounting = false;
2529
+ }
2530
+ if (newCursor < records.length) {
2531
+ this.armChunkSentinel(records, newCursor, context, generation, animateImageHydration);
2532
+ }
2533
+ });
2534
+ };
2535
+ if (!observerWindow?.IntersectionObserver) {
2536
+ mountNext(cursor);
2537
+ return;
2538
+ }
2539
+ const sentinel = doc.createElement('div');
2540
+ sentinel.className = 'mdzip-chunk-sentinel';
2541
+ this.elPreviewContent.appendChild(sentinel);
2542
+ const observer = new observerWindow.IntersectionObserver((entries) => {
2543
+ for (const entry of entries) {
2544
+ if (!entry.isIntersecting)
2545
+ continue;
2546
+ observer.disconnect();
2547
+ sentinel.remove();
2548
+ if (this.chunkedRenderState?.generation === generation) {
2549
+ this.chunkedRenderState.sentinelHandle = null;
2550
+ }
2551
+ mountNext(cursor);
2552
+ }
2553
+ }, { root: this.elPreviewPane, rootMargin: '1600px 0px' });
2554
+ observer.observe(sentinel);
2555
+ // Stored on both previewHandles (torn down on the next render generation,
2556
+ // like every other preview handle) and chunkedRenderState.sentinelHandle
2557
+ // (torn down early by Copy All so its own manual drain doesn't race this
2558
+ // observer and double-mount a chunk).
2559
+ const handle = { destroy: () => { observer.disconnect(); sentinel.remove(); } };
2560
+ this.previewHandles.push(handle);
2561
+ if (this.chunkedRenderState?.generation === generation) {
2562
+ this.chunkedRenderState.sentinelHandle = handle;
1548
2563
  }
1549
2564
  }
1550
2565
  /**
@@ -1607,13 +2622,20 @@ export class MdzipWorkspaceView {
1607
2622
  + 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
1608
2623
  + 'blob: and data:.'));
1609
2624
  }
1610
- mountPreviewExtensions(context, generation) {
2625
+ /**
2626
+ * `root` scopes extension `mount()` calls to a specific chunk instead of
2627
+ * the whole preview — used by chunked rendering (see
2628
+ * {@link mountChunkedPreview}), where extensions run once per newly
2629
+ * appended chunk rather than once over the whole document. Defaults to
2630
+ * the whole preview content, matching the non-chunked path exactly.
2631
+ */
2632
+ mountPreviewExtensions(context, generation, root = this.elPreviewContent, collector = this.previewHandles) {
1611
2633
  for (const extension of this.markdownExtensions) {
1612
2634
  if (!extension.mount) {
1613
2635
  continue;
1614
2636
  }
1615
2637
  try {
1616
- const mounted = extension.mount(this.elPreviewContent, context);
2638
+ const mounted = extension.mount(root, context);
1617
2639
  if (isThenable(mounted)) {
1618
2640
  void Promise.resolve(mounted).then((handle) => {
1619
2641
  if (!handle) {
@@ -1628,7 +2650,7 @@ export class MdzipWorkspaceView {
1628
2650
  }
1629
2651
  return;
1630
2652
  }
1631
- this.previewHandles.push(handle);
2653
+ collector.push(handle);
1632
2654
  }).catch((error) => {
1633
2655
  if (generation === this.previewGeneration) {
1634
2656
  this.options.onFailed?.(error);
@@ -1636,7 +2658,7 @@ export class MdzipWorkspaceView {
1636
2658
  });
1637
2659
  }
1638
2660
  else if (mounted) {
1639
- this.previewHandles.push(mounted);
2661
+ collector.push(mounted);
1640
2662
  }
1641
2663
  }
1642
2664
  catch (error) {
@@ -1653,12 +2675,15 @@ export class MdzipWorkspaceView {
1653
2675
  * extension) — every consumer gets this automatically, gated by policy
1654
2676
  * rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
1655
2677
  * extension-provided `pre > code` blocks already exist in the DOM. See #29.
2678
+ *
2679
+ * `root` scopes the scan to a specific chunk instead of the whole preview
2680
+ * — see {@link mountPreviewExtensions} for why.
1656
2681
  */
1657
- mountCodeBlockControls() {
2682
+ mountCodeBlockControls(root = this.elPreviewContent) {
1658
2683
  if (!this.controlPolicy.codeBlockTools) {
1659
2684
  return null;
1660
2685
  }
1661
- const codeEls = Array.from(this.elPreviewContent.querySelectorAll('pre > code'));
2686
+ const codeEls = Array.from(root.querySelectorAll('pre > code'));
1662
2687
  if (codeEls.length === 0) {
1663
2688
  return null;
1664
2689
  }
@@ -1772,6 +2797,7 @@ export class MdzipWorkspaceView {
1772
2797
  if (generation !== this.previewGeneration) {
1773
2798
  return;
1774
2799
  }
2800
+ this.restorePendingPreviewScroll(generation);
1775
2801
  try {
1776
2802
  this.options.onPreviewRendered?.(snapshot);
1777
2803
  }
@@ -1779,6 +2805,20 @@ export class MdzipWorkspaceView {
1779
2805
  this.options.onFailed?.(error);
1780
2806
  }
1781
2807
  }
2808
+ /** See `pendingPreviewScrollRestore`'s doc comment. */
2809
+ restorePendingPreviewScroll(generation) {
2810
+ const pending = this.pendingPreviewScrollRestore;
2811
+ if (!pending || pending.generation !== generation) {
2812
+ return;
2813
+ }
2814
+ this.pendingPreviewScrollRestore = null;
2815
+ if (this.elPreviewPane.scrollTop !== pending.scrollTop) {
2816
+ this.syncing = true;
2817
+ this.lastSyncedPreviewScrollTop = pending.scrollTop;
2818
+ this.elPreviewPane.scrollTop = pending.scrollTop;
2819
+ this.syncing = false;
2820
+ }
2821
+ }
1782
2822
  fireAssetsHydrated(snapshot, generation) {
1783
2823
  if (generation !== this.previewGeneration) {
1784
2824
  return;
@@ -2001,6 +3041,8 @@ export class MdzipWorkspaceView {
2001
3041
  syntaxHighlighting(mdzipMarkdownHighlight),
2002
3042
  hardBreakMarkerHighlight,
2003
3043
  htmlTagMarkerHighlight,
3044
+ noSpellcheckHighlight,
3045
+ imageEditAffordanceField,
2004
3046
  EditorView.lineWrapping,
2005
3047
  dropCursor(),
2006
3048
  // Content is contenteditable, but browsers don't agree on a default
@@ -2056,6 +3098,23 @@ export class MdzipWorkspaceView {
2056
3098
  void self.handleEditorImageDrop(file, event.clientX, event.clientY);
2057
3099
  return true;
2058
3100
  }
3101
+ },
3102
+ click(event, view) {
3103
+ // Fully inert unless a host has opted in — no parse cost, no
3104
+ // widget, matching #39's "default editor behavior unchanged".
3105
+ if (!self.options.imageEditHandler) {
3106
+ return;
3107
+ }
3108
+ const target = event.target;
3109
+ if (target.closest('[data-mdzip-image-edit-affordance]')) {
3110
+ return;
3111
+ }
3112
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
3113
+ const hit = pos === null ? null : findImageReferenceAtOffset(view.state, pos);
3114
+ view.dispatch({ effects: imageEditAffordanceEffect.of(hit ? { from: hit.from, to: hit.to } : null) });
3115
+ },
3116
+ blur(_event, view) {
3117
+ view.dispatch({ effects: imageEditAffordanceEffect.of(null) });
2059
3118
  }
2060
3119
  }),
2061
3120
  ],
@@ -2064,8 +3123,25 @@ export class MdzipWorkspaceView {
2064
3123
  // Attach scroll listener to sync with preview
2065
3124
  const scroller = editor.dom.querySelector('.cm-scroller');
2066
3125
  if (scroller) {
3126
+ // No 'keydown' here, deliberately: .cm-scroller receives every keystroke
3127
+ // typed into the editor, not just navigation keys, so it can't tell
3128
+ // "user pressed PageDown to scroll" apart from "user typed a letter" —
3129
+ // confirmed live, where ordinary typing was arming this gesture window
3130
+ // and letting CodeMirror's own small post-edit viewport-correction
3131
+ // scroll (a real native behavior) through as if it were a deliberate
3132
+ // scroll, on every keystroke. Wheel/touch/mousedown (which also covers
3133
+ // a scrollbar-thumb drag, since that lands on this same element) are
3134
+ // unambiguous and cover the vast majority of real scrolling.
3135
+ const markEditorGesture = () => { self.lastEditorGestureTime = performance.now(); };
3136
+ scroller.addEventListener('wheel', markEditorGesture, { passive: true });
3137
+ scroller.addEventListener('touchstart', markEditorGesture, { passive: true });
3138
+ scroller.addEventListener('mousedown', markEditorGesture);
2067
3139
  scroller.addEventListener('scroll', () => self.syncScrollToPreview());
2068
3140
  }
3141
+ editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
3142
+ const { from, to } = event.detail;
3143
+ void self.openImageEditFlow(from, to);
3144
+ });
2069
3145
  return editor;
2070
3146
  }
2071
3147
  async ensureCmEditor(force = false) {
@@ -2081,6 +3157,23 @@ export class MdzipWorkspaceView {
2081
3157
  const snapshot = this.workspace?.snapshot() ?? null;
2082
3158
  this.elEmptyState.hidden = snapshot !== null;
2083
3159
  this.elWorkspaceShell.hidden = snapshot === null;
3160
+ // Must run before the no-snapshot early return below: the pack-files dialog is
3161
+ // deciding how to create a workspace, so it opens (via openPackFilesDialog ->
3162
+ // render()) precisely when no workspace exists yet. Bug found 2026-08-19 —
3163
+ // this used to live after the return, making it dead code for this dialog's
3164
+ // entire lifetime; the dialog's state was set correctly but never reached the
3165
+ // DOM, leaving the empty-state placeholder showing and the caller's promise
3166
+ // hanging forever.
3167
+ this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
3168
+ if (this.packFilesDialogState) {
3169
+ const { request } = this.packFilesDialogState;
3170
+ this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
3171
+ .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
3172
+ .join('');
3173
+ this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
3174
+ this.elPackFilesModeDocument.checked = true;
3175
+ this.elPackFilesModeProject.checked = false;
3176
+ }
2084
3177
  if (!snapshot) {
2085
3178
  this.elDocumentStrip.hidden = true;
2086
3179
  this.elToolbar.hidden = true;
@@ -2265,15 +3358,26 @@ export class MdzipWorkspaceView {
2265
3358
  this.elImageInsertPositionSelect.value = 'inline';
2266
3359
  this.updateImageInsertOptionControls();
2267
3360
  }
2268
- this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
2269
- if (this.packFilesDialogState) {
2270
- const { request } = this.packFilesDialogState;
2271
- this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
2272
- .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
2273
- .join('');
2274
- this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
2275
- this.elPackFilesModeDocument.checked = true;
2276
- this.elPackFilesModeProject.checked = false;
3361
+ this.elCopyRenderDialog.hidden = this.copyRenderDialogState === null
3362
+ && this.copyReadyState === null
3363
+ && this.copyRenderDoneState === null;
3364
+ this.elCopyRenderProgressSection.hidden = this.copyRenderDialogState === null;
3365
+ this.elCopyRenderReadyText.hidden = this.copyReadyState === null;
3366
+ this.elCopyRenderDoneText.hidden = this.copyRenderDoneState === null;
3367
+ this.elCopyRenderCancelBtn.hidden = this.copyRenderDialogState === null;
3368
+ this.elCopyRenderReadyCancelBtn.hidden = this.copyReadyState === null;
3369
+ this.elCopyRenderConfirmBtn.hidden = this.copyReadyState === null;
3370
+ this.elCopyRenderDismissBtn.hidden = this.copyRenderDoneState === null;
3371
+ if (this.copyRenderDialogState) {
3372
+ this.elCopyRenderHeading.textContent = 'Copying document...';
3373
+ this.updateCopyRenderDialogProgress();
3374
+ }
3375
+ else if (this.copyReadyState) {
3376
+ this.elCopyRenderHeading.textContent = 'Ready to copy';
3377
+ }
3378
+ else if (this.copyRenderDoneState) {
3379
+ this.elCopyRenderHeading.textContent = 'Done';
3380
+ this.elCopyRenderDoneText.textContent = this.copyRenderDoneState.message;
2277
3381
  }
2278
3382
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
2279
3383
  if (this.contextMenuState) {
@@ -2367,10 +3471,11 @@ export class MdzipWorkspaceView {
2367
3471
  }
2368
3472
  });
2369
3473
  // Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
2370
- // both panes' text at once. Scope it to the rendered content instead when
2371
- // focus is inside the preview pane (see the mousedown handler below for
2372
- // how it gets there); the source editor keeps its own defaultKeymap
2373
- // binding since CodeMirror's content root sits outside this element.
3474
+ // both panes' text at once. Scope it to a Copy All of the rendered
3475
+ // content instead when focus is inside the preview pane (see the
3476
+ // mousedown handler below for how it gets there); the source editor
3477
+ // keeps its own defaultKeymap binding since CodeMirror's content root
3478
+ // sits outside this element.
2374
3479
  doc.addEventListener('keydown', (e) => {
2375
3480
  if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
2376
3481
  return;
@@ -2379,7 +3484,7 @@ export class MdzipWorkspaceView {
2379
3484
  return;
2380
3485
  }
2381
3486
  e.preventDefault();
2382
- this.selectAllPreviewContent();
3487
+ void this.copyAllPreviewContent();
2383
3488
  });
2384
3489
  this.elNavBtn.addEventListener('click', () => {
2385
3490
  if (!this.controlPolicy.navigation) {
@@ -2650,6 +3755,28 @@ export class MdzipWorkspaceView {
2650
3755
  this.elPackFilesConfirmBtn.addEventListener('click', () => {
2651
3756
  this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
2652
3757
  });
3758
+ this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-render"]')
3759
+ .addEventListener('click', () => {
3760
+ this.copyRenderDialogState?.abort.abort();
3761
+ });
3762
+ this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-ready"]')
3763
+ .addEventListener('click', () => {
3764
+ this.copyReadyState = null;
3765
+ this.render();
3766
+ });
3767
+ this.elCopyRenderDialog.querySelector('[data-action="confirm-copy-ready"]')
3768
+ .addEventListener('click', () => {
3769
+ // Must call the actual write synchronously from this handler (no
3770
+ // awaits ahead of it) — this click is the fresh user gesture the
3771
+ // Clipboard API needs; losing it to another async hop before the
3772
+ // write starts would defeat the entire reason this button exists.
3773
+ void this.performReadyCopy();
3774
+ });
3775
+ this.elCopyRenderDialog.querySelector('[data-action="dismiss-copy-render"]')
3776
+ .addEventListener('click', () => {
3777
+ this.copyRenderDoneState = null;
3778
+ this.render();
3779
+ });
2653
3780
  this.elNavMenu.addEventListener('click', (e) => {
2654
3781
  e.stopPropagation();
2655
3782
  const item = e.target.closest('[data-menu-action]');
@@ -2782,6 +3909,11 @@ export class MdzipWorkspaceView {
2782
3909
  void this.replaceFileFromPicker(path, file);
2783
3910
  }
2784
3911
  });
3912
+ const markPreviewGesture = () => { this.lastPreviewGestureTime = performance.now(); };
3913
+ this.elPreviewPane.addEventListener('wheel', markPreviewGesture, { passive: true });
3914
+ this.elPreviewPane.addEventListener('touchstart', markPreviewGesture, { passive: true });
3915
+ this.elPreviewPane.addEventListener('mousedown', markPreviewGesture);
3916
+ this.elPreviewPane.addEventListener('keydown', markPreviewGesture);
2785
3917
  this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
2786
3918
  // Gives the pane logical focus on click so a subsequent Ctrl/Cmd+A (below)
2787
3919
  // can be scoped to it. tabindex="-1" keeps it out of Tab order; this is
@@ -2915,6 +4047,18 @@ export class MdzipWorkspaceView {
2915
4047
  this.tooltipHideTimer = null;
2916
4048
  }, 40);
2917
4049
  }
4050
+ /** Briefly shows a status message (e.g. "Text copied to clipboard") near the bottom of the view, auto-hiding after a couple of seconds. */
4051
+ showCopyToast(message) {
4052
+ if (this.copyToastHideTimer) {
4053
+ clearTimeout(this.copyToastHideTimer);
4054
+ }
4055
+ this.elCopyToast.textContent = message;
4056
+ this.elCopyToast.hidden = false;
4057
+ this.copyToastHideTimer = setTimeout(() => {
4058
+ this.elCopyToast.hidden = true;
4059
+ this.copyToastHideTimer = null;
4060
+ }, 2000);
4061
+ }
2918
4062
  async save() {
2919
4063
  try {
2920
4064
  const workspace = this.workspace;
@@ -2969,15 +4113,35 @@ export class MdzipWorkspaceView {
2969
4113
  }
2970
4114
  renderMetadata(snapshot) {
2971
4115
  const manifest = snapshot.content.manifest;
4116
+ const isMdz = snapshot.sourceFormat === 'mdz';
2972
4117
  const fields = [
2973
4118
  ['Filename', snapshot.fileName],
2974
- ['Format', snapshot.sourceFormat === 'mdz' ? 'MDZ package' : 'Markdown'],
4119
+ ['Format', isMdz ? 'MDZ package' : 'Markdown'],
4120
+ // For .mdz, archiveBytes really is what a save right now would write —
4121
+ // current in-memory bytes, edits included, not a stale re-read of disk.
4122
+ // For plain Markdown, archiveBytes is *not* that: it's some internally
4123
+ // wrapped representation with its own fixed overhead (verified: a
4124
+ // 5-byte markdown document reported 462 "archive" bytes) — use the
4125
+ // actual text's encoded size instead, which is what a .md save writes.
4126
+ ['Size', formatByteSize(isMdz ? snapshot.archiveBytes.length : new TextEncoder().encode(snapshot.currentText).length)],
2975
4127
  ['Document title', snapshot.displayTitle],
2976
4128
  ['First heading', snapshot.headingFallback ?? 'Not found'],
2977
4129
  ['Created', formatMetadataValue(manifest?.created)],
2978
4130
  ['Modified', formatMetadataValue(manifest?.modified)],
2979
- ['Entry point', snapshot.sourceFormat === 'mdz' ? snapshot.content.entryPoint : 'Not applicable']
4131
+ ['Entry point', isMdz ? snapshot.content.entryPoint : 'Not applicable'],
4132
+ ['Documents', isMdz ? String(snapshot.workspace.documents.length) : 'Not applicable'],
4133
+ ['Assets', isMdz ? String(snapshot.workspace.assets.length) : 'Not applicable']
2980
4134
  ];
4135
+ // Only shown when actually read-only — most documents are editable, and a
4136
+ // "Read-only: No" row for the common case would just be noise. Spelled
4137
+ // out as a filesystem condition rather than an editor state: this mode is
4138
+ // driven entirely by the host (see MdzipWorkspaceOpenOptions.mode) — most
4139
+ // often because a host checked the file's OS/disk permissions, as the
4140
+ // vscode extension does — not something toggled inside the editor itself,
4141
+ // so the wording should point users at their file, not at this UI.
4142
+ if (snapshot.mode === 'read-only') {
4143
+ fields.splice(1, 0, ['Read-only', 'Yes — the file on disk (or its host) is not writable']);
4144
+ }
2981
4145
  this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
2982
4146
  return this.createMetadataRow(label, value);
2983
4147
  }));
@@ -3293,6 +4457,61 @@ export class MdzipWorkspaceView {
3293
4457
  this.cmEditor.focus();
3294
4458
  }
3295
4459
  }
4460
+ /**
4461
+ * Handles a click on an existing image's edit affordance (see
4462
+ * `imageEditAffordanceField`/the `click` domEventHandler in
4463
+ * createCmEditor). Only reachable when `imageEditHandler` is set — the
4464
+ * click handler that dispatches the affordance already gates on that.
4465
+ */
4466
+ async openImageEditFlow(from, to) {
4467
+ const editor = this.cmEditor;
4468
+ const handler = this.options.imageEditHandler;
4469
+ if (!editor || !handler) {
4470
+ return;
4471
+ }
4472
+ const parsed = findImageReferenceAtOffset(editor.state, from);
4473
+ if (!parsed || parsed.from !== from || parsed.to !== to) {
4474
+ return;
4475
+ }
4476
+ const request = {
4477
+ src: parsed.src,
4478
+ altText: parsed.altText,
4479
+ width: parsed.width,
4480
+ height: parsed.height,
4481
+ position: parsed.position,
4482
+ mode: parsed.kind
4483
+ };
4484
+ let decision;
4485
+ try {
4486
+ decision = normalizeImageInsertDecision(await handler(request));
4487
+ }
4488
+ catch (error) {
4489
+ this.options.onFailed?.(error);
4490
+ decision = null;
4491
+ }
4492
+ this.cmEditor?.dispatch({ effects: imageEditAffordanceEffect.of(null) });
4493
+ if (!decision) {
4494
+ return; // cancelled
4495
+ }
4496
+ const current = this.cmEditor;
4497
+ if (!current) {
4498
+ return;
4499
+ }
4500
+ // The handler may have awaited arbitrarily long — revalidate against the
4501
+ // live doc instead of trusting the captured range/text, in case it
4502
+ // changed underneath while the dialog was open.
4503
+ const revalidated = findImageReferenceAtOffset(current.state, from);
4504
+ if (!revalidated || revalidated.raw !== parsed.raw) {
4505
+ this.options.onFailed?.(new Error('Image reference changed before the edit could be applied.'));
4506
+ return;
4507
+ }
4508
+ const replacement = formatImageEditMarkdown(revalidated.src, decision);
4509
+ current.dispatch({
4510
+ changes: { from: revalidated.from, to: revalidated.to, insert: replacement },
4511
+ selection: { anchor: revalidated.from + replacement.length }
4512
+ });
4513
+ current.focus();
4514
+ }
3296
4515
  async resolveImageInsertDecision(bytes, mimeType, options) {
3297
4516
  const size = sniffImageSize(bytes, mimeType);
3298
4517
  const request = {
@@ -3589,7 +4808,7 @@ export class MdzipWorkspaceView {
3589
4808
  }
3590
4809
  }
3591
4810
  // Items for the rendered-preview selection menu. Taking over `contextmenu`
3592
- // to offer Select All also suppresses the browser's native menu — which is
4811
+ // to offer Copy All also suppresses the browser's native menu — which is
3593
4812
  // the only thing that was otherwise offering Copy on a right-click — so
3594
4813
  // Copy has to be reinstated explicitly whenever there's a selection.
3595
4814
  previewMenuItems() {
@@ -3603,11 +4822,16 @@ export class MdzipWorkspaceView {
3603
4822
  items.push(null);
3604
4823
  }
3605
4824
  items.push({
3606
- action: 'preview-select-all',
3607
- label: 'Select All',
3608
- icon: MENU_SELECT_ALL_ICON_HTML,
4825
+ action: 'preview-copy-all',
4826
+ label: 'Copy All',
4827
+ icon: MENU_COPY_ICON_HTML,
3609
4828
  shortcut: this.editorShortcut('A')
3610
4829
  });
4830
+ items.push({
4831
+ action: 'preview-copy-all-images',
4832
+ label: 'Copy All with Images',
4833
+ icon: MENU_COPY_ICON_HTML
4834
+ });
3611
4835
  return items;
3612
4836
  }
3613
4837
  handlePreviewMenuAction(action) {
@@ -3618,41 +4842,458 @@ export class MdzipWorkspaceView {
3618
4842
  return;
3619
4843
  }
3620
4844
  switch (action) {
3621
- case 'preview-select-all':
3622
- this.selectAllPreviewContent();
4845
+ case 'preview-copy-all':
4846
+ void this.copyAllPreviewContent();
4847
+ break;
4848
+ case 'preview-copy-all-images':
4849
+ void this.copyAllWithImagesPreviewContent();
3623
4850
  break;
3624
4851
  case 'preview-copy':
3625
- void this.copyPreviewSelection(state.text);
4852
+ // Partial Copy never shows the render dialog (there's nothing to
4853
+ // drain), so its confirmation is always the brief toast.
4854
+ void this.copyPreviewSelection(state.text).then((outcome) => this.finishCopyNotification(false, outcome));
3626
4855
  break;
3627
4856
  }
3628
4857
  }
4858
+ /**
4859
+ * Shows the copy confirmation either as a brief auto-hiding toast (an
4860
+ * instant copy — nothing for the user to have looked away from) or, if
4861
+ * the render dialog was showing, by switching that same dialog into a
4862
+ * dismissable "done" state instead of hiding it — a copy that took long
4863
+ * enough to need a progress dialog shouldn't end in a 2-second toast the
4864
+ * user may not be looking at. `outcome` null means there was nothing to
4865
+ * copy (empty selection) — silently clean up the dialog if one was
4866
+ * showing, no confirmation needed. An `error` outcome carries the actual
4867
+ * failure text inline (`onFailed` is a host callback with no guaranteed
4868
+ * visible surface — this component has no other way to guarantee the
4869
+ * user ever sees why it failed).
4870
+ */
4871
+ finishCopyNotification(dialogWasShown, outcome) {
4872
+ if (!outcome) {
4873
+ if (dialogWasShown) {
4874
+ this.copyRenderDialogState = null;
4875
+ this.render();
4876
+ }
4877
+ return;
4878
+ }
4879
+ const text = 'error' in outcome ? `Copy failed: ${outcome.error}` : outcome.message;
4880
+ if (dialogWasShown) {
4881
+ this.copyRenderDialogState = null;
4882
+ this.copyRenderDoneState = { message: text };
4883
+ this.render();
4884
+ }
4885
+ else {
4886
+ this.showCopyToast(text);
4887
+ }
4888
+ }
4889
+ /** Switches the render dialog into its "ready to copy" state, holding `perform` until the user clicks Copy — see `copyReadyState` for why the write can't just happen automatically here. */
4890
+ armCopyReady(perform) {
4891
+ this.copyRenderDialogState = null;
4892
+ this.copyReadyState = { perform };
4893
+ this.render();
4894
+ }
4895
+ /** Runs the held write from `copyReadyState` — called directly from the dialog's Copy button click, which is what makes the write's own user-activation check pass. */
4896
+ async performReadyCopy() {
4897
+ const ready = this.copyReadyState;
4898
+ if (!ready)
4899
+ return;
4900
+ this.copyReadyState = null;
4901
+ const outcome = await ready.perform();
4902
+ // Always the dialog path: copyReadyState only ever gets armed for a
4903
+ // copy that already showed the progress dialog.
4904
+ this.finishCopyNotification(true, outcome);
4905
+ }
4906
+ /**
4907
+ * Races `promise` against a timer, rejecting with `timeoutMessage` if the
4908
+ * timer wins. The async Clipboard API has no built-in timeout, and a
4909
+ * write large enough to strain a real OS clipboard (a document with
4910
+ * thousands of embedded images can build a HTML payload well over
4911
+ * 100MB) can apparently hang indefinitely on some machines rather than
4912
+ * rejecting — without this, that leaves the operation permanently
4913
+ * pending, and the progress dialog never resolves into either a done or
4914
+ * a failure state.
4915
+ */
4916
+ async withTimeout(promise, timeoutMs, timeoutMessage) {
4917
+ let timer;
4918
+ const timeout = new Promise((_, reject) => {
4919
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
4920
+ });
4921
+ try {
4922
+ return await Promise.race([promise, timeout]);
4923
+ }
4924
+ finally {
4925
+ clearTimeout(timer);
4926
+ }
4927
+ }
4928
+ /** Writes `text` to the clipboard. Returns null if there was nothing to copy (a true no-op, not a failure); otherwise a success message or the failure text (also reported via `onFailed`, but that's a host callback with no guaranteed visible surface — the caller needs the text itself to show the user). */
3629
4929
  async copyPreviewSelection(text) {
3630
4930
  if (!text) {
3631
- return;
4931
+ return null;
3632
4932
  }
3633
4933
  try {
3634
- await this.editorClipboard()?.writeText(text);
4934
+ await this.withTimeout(Promise.resolve(this.editorClipboard()?.writeText(text)), this.clipboardFallbackWriteTimeoutMs, 'Clipboard write timed out.');
4935
+ return { message: 'Plain text copied to clipboard' };
3635
4936
  }
3636
4937
  catch (error) {
3637
4938
  this.options.onFailed?.(error);
4939
+ return { error: error instanceof Error ? error.message : String(error) };
3638
4940
  }
3639
4941
  }
3640
- // Selects the full rendered content of the preview pane, scoped to that
3641
- // pane rather than the whole page (native Ctrl+A / right-click Select All
3642
- // would otherwise grab the source editor's text too in split layout).
3643
- selectAllPreviewContent() {
3644
- const doc = this.elPreviewContent.ownerDocument;
3645
- const selection = doc.defaultView?.getSelection();
3646
- if (!selection) {
4942
+ /** Syncs just the copy-render dialog's progress bar/text from `copyRenderDialogState` — see `copyAllPreviewContent` for why this bypasses `render()`. No-op while the dialog isn't showing. */
4943
+ updateCopyRenderDialogProgress() {
4944
+ if (!this.copyRenderDialogState) {
4945
+ return;
4946
+ }
4947
+ const { done, total, label } = this.copyRenderDialogState;
4948
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
4949
+ this.elCopyRenderProgressBar.style.width = `${percent}%`;
4950
+ this.elCopyRenderProgressText.textContent = total > 0 ? `${label} (${done} / ${total})...` : `${label}...`;
4951
+ }
4952
+ /**
4953
+ * Copies the entire rendered document as plain text (same fidelity as
4954
+ * `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
4955
+ * much of it is currently mounted under progressive rendering. If
4956
+ * everything's already mounted (small doc, eager/non-progressive mount, or
4957
+ * the user already scrolled through it) this is instant —
4958
+ * `chunkedRenderState` is either null (no markdown open, or a non-default
4959
+ * renderer) or has nothing left to mount (`cursor >= records.length`) in
4960
+ * exactly that case. Otherwise it force-drains the rest first, showing a
4961
+ * cancelable progress dialog once the wait clears a short debounce so fast
4962
+ * documents never flicker it into view.
4963
+ */
4964
+ async copyAllPreviewContent() {
4965
+ const pending = this.chunkedRenderState;
4966
+ if (!pending || pending.cursor >= pending.records.length) {
4967
+ const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
4968
+ this.finishCopyNotification(false, outcome);
4969
+ return;
4970
+ }
4971
+ const total = pending.records.length;
4972
+ const abort = new AbortController();
4973
+ let dialogShown = false;
4974
+ const label = 'Rendering the full document';
4975
+ const showDialogTimer = setTimeout(() => {
4976
+ dialogShown = true;
4977
+ this.copyRenderDialogState = { done: pending.cursor, total, label, abort };
4978
+ this.render();
4979
+ }, 200);
4980
+ // Progress ticks update the dialog's progress bar/text directly instead
4981
+ // of going through the view's full `render()` — profiling a drain on a
4982
+ // 15,000-image document showed `render()` alone (it re-syncs the whole
4983
+ // shell, e.g. rebuilding the nav tree) costing ~870ms of self-time over
4984
+ // a 4s sample even throttled to 10/sec, dwarfing the actual chunk-mount
4985
+ // work it was meant to make room for.
4986
+ await this.drainRemainingChunks(pending, (done) => {
4987
+ if (!dialogShown)
4988
+ return;
4989
+ this.copyRenderDialogState = { done, total, label, abort };
4990
+ this.updateCopyRenderDialogProgress();
4991
+ }, abort.signal);
4992
+ clearTimeout(showDialogTimer);
4993
+ if (abort.signal.aborted) {
4994
+ if (dialogShown) {
4995
+ this.copyRenderDialogState = null;
4996
+ this.render();
4997
+ }
4998
+ // Cancelled partway through — whatever's left stays unmounted, so
4999
+ // re-arm the usual scroll-driven continuation for it.
5000
+ const remaining = this.chunkedRenderState;
5001
+ if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle
5002
+ && remaining.cursor < remaining.records.length) {
5003
+ this.armChunkSentinel(remaining.records, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
5004
+ }
5005
+ return;
5006
+ }
5007
+ if (dialogShown) {
5008
+ // The prepare phase above can take anywhere from seconds to minutes —
5009
+ // long enough that the click which started this has lost its user
5010
+ // activation by now, so the actual write waits for a fresh one
5011
+ // (the dialog's own Copy button) instead of firing automatically.
5012
+ // See copyReadyState's doc comment.
5013
+ this.armCopyReady(() => this.copyPreviewSelection(this.elPreviewContent.textContent ?? ''));
3647
5014
  return;
3648
5015
  }
3649
- // Focus before selecting: focusing the pane after building the range
3650
- // collapses the selection right back out.
3651
- this.elPreviewPane.focus({ preventScroll: true });
3652
- const range = doc.createRange();
3653
- range.selectNodeContents(this.elPreviewContent);
3654
- selection.removeAllRanges();
3655
- selection.addRange(range);
5016
+ const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
5017
+ this.finishCopyNotification(false, outcome);
5018
+ }
5019
+ /**
5020
+ * Renders the *entire* current document to one HTML string, independent
5021
+ * of whatever's mounted in `elPreviewContent` — no DOM reads or writes.
5022
+ * Copy All with Images needs pristine `<img src="original/path">` markup
5023
+ * to hand to `MdzipAssetSession.rewriteHtmlEmbeddingImages`, and an
5024
+ * already-mounted, possibly-hydrated `<img>` in the live preview may have
5025
+ * had its `src` swapped for a `blob:` URL or stripped entirely pending lazy
5026
+ * load — either way the original archive path is gone. Time-budgeted and
5027
+ * yielded like `renderAndMountChunkBatch`, for the same reason: some
5028
+ * chunks cost far more to render+sanitize than others. Extension `mount()`
5029
+ * hooks are not run — they render into a live DOM (e.g. mermaid diagrams
5030
+ * turning marked-up code fences into SVG), which this never touches, so a
5031
+ * mermaid diagram will paste as its pre-render markup, not a rendered
5032
+ * diagram. Only reachable when `renderingService.supportsChunking` is
5033
+ * true; the caller checks that first.
5034
+ */
5035
+ async renderFullDocumentHtml(snapshot, context, signal, cacheGeneration, onProgress) {
5036
+ const tokensResult = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
5037
+ const tokens = Array.isArray(tokensResult) ? tokensResult : await tokensResult;
5038
+ if (signal.aborted) {
5039
+ throw new DOMException('Rendering aborted.', 'AbortError');
5040
+ }
5041
+ // groupTokensIntoChunks is also called with no options in
5042
+ // renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
5043
+ // call sites' chunking in sync, since chunk index is chunkHtmlCache's
5044
+ // only key.
5045
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
5046
+ const view = this.elPreviewContent.ownerDocument.defaultView;
5047
+ const clock = view?.performance ?? performance;
5048
+ const BATCH_TIME_BUDGET_MS = 10;
5049
+ let html = '';
5050
+ let cursor = 0;
5051
+ while (cursor < chunks.length) {
5052
+ if (signal.aborted) {
5053
+ throw new DOMException('Rendering aborted.', 'AbortError');
5054
+ }
5055
+ const batchStart = clock.now();
5056
+ let processedInBatch = 0;
5057
+ while (cursor < chunks.length && (processedInBatch === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
5058
+ const chunkIndex = cursor;
5059
+ const cacheHit = cacheGeneration !== null && this.chunkHtmlCache?.generation === cacheGeneration
5060
+ ? this.chunkHtmlCache.html.get(chunkIndex)
5061
+ : undefined;
5062
+ let chunkHtml;
5063
+ if (cacheHit !== undefined) {
5064
+ chunkHtml = cacheHit;
5065
+ }
5066
+ else {
5067
+ const result = this.renderingService.renderChunk(chunks[chunkIndex], context);
5068
+ chunkHtml = typeof result === 'string' ? result : await result;
5069
+ // Only populate the shared cache if this generation is *still*
5070
+ // the live one at the moment this (possibly async) render
5071
+ // finished — checked per-chunk, not once up front, since the
5072
+ // outer loop yields via requestAnimationFrame and individual
5073
+ // chunk renders (mermaid) can themselves be async. If the
5074
+ // document was edited mid-copy, cacheGeneration is now stale
5075
+ // even though this render (for the snapshot Copy All captured)
5076
+ // legitimately continues to completion — never write it back
5077
+ // under a generation number that's no longer current.
5078
+ if (cacheGeneration !== null && cacheGeneration === this.previewGeneration) {
5079
+ if (this.chunkHtmlCache?.generation !== cacheGeneration) {
5080
+ this.chunkHtmlCache = { generation: cacheGeneration, html: new Map() };
5081
+ }
5082
+ this.chunkHtmlCache.html.set(chunkIndex, chunkHtml);
5083
+ }
5084
+ }
5085
+ html += chunkHtml;
5086
+ cursor += 1;
5087
+ processedInBatch += 1;
5088
+ }
5089
+ onProgress?.(cursor, chunks.length);
5090
+ if (cursor >= chunks.length) {
5091
+ break;
5092
+ }
5093
+ if (signal.aborted) {
5094
+ throw new DOMException('Rendering aborted.', 'AbortError');
5095
+ }
5096
+ await new Promise((resolve) => {
5097
+ if (view?.requestAnimationFrame)
5098
+ view.requestAnimationFrame(() => resolve());
5099
+ else
5100
+ setTimeout(resolve, 0);
5101
+ });
5102
+ }
5103
+ return html;
5104
+ }
5105
+ /**
5106
+ * Like `copyAllPreviewContent`, but writes a rich `text/html` clipboard
5107
+ * representation (alongside the same `text/plain` fallback) with every
5108
+ * archive image re-embedded as a self-contained `data:` URL — the format
5109
+ * an external app like Word needs, since this document's own `blob:` URLs
5110
+ * only resolve inside this tab. Two phases share one debounced, cancelable
5111
+ * dialog: render the document fresh (`renderFullDocumentHtml`), then embed
5112
+ * its images (`rewriteHtmlEmbeddingImages`). Falls back to
5113
+ * `copyAllPreviewContent`'s plain-text-only behavior when there's no
5114
+ * default renderer to re-render from (a host-supplied custom renderer,
5115
+ * the same escape hatch `progressiveTextRendering` already has) — or, if
5116
+ * the clipboard rejects the rich write for any reason (unsupported
5117
+ * browser, payload too large), falls back to a plain-text write so the
5118
+ * user still gets *something* rather than nothing.
5119
+ */
5120
+ async copyAllWithImagesPreviewContent() {
5121
+ const snapshot = this.workspace?.snapshot();
5122
+ if (!snapshot || snapshot.currentPathType !== 'markdown' || !this.renderingService.supportsChunking) {
5123
+ await this.copyAllPreviewContent();
5124
+ return;
5125
+ }
5126
+ const abort = new AbortController();
5127
+ let dialogShown = false;
5128
+ const showDialogTimer = setTimeout(() => {
5129
+ dialogShown = true;
5130
+ this.copyRenderDialogState = { done: 0, total: 0, label: 'Rendering the document', abort };
5131
+ this.render();
5132
+ }, 200);
5133
+ const updateProgress = (done, total, label) => {
5134
+ if (!dialogShown)
5135
+ return;
5136
+ this.copyRenderDialogState = { done, total, label, abort };
5137
+ this.updateCopyRenderDialogProgress();
5138
+ };
5139
+ let finalHtml;
5140
+ let imageCount = 0;
5141
+ try {
5142
+ const context = this.createMarkdownContext(snapshot, abort.signal);
5143
+ // Only trust chunkHtmlCache if the live preview generation still
5144
+ // represents this exact snapshot — if the preview hasn't caught up to
5145
+ // it yet, or the document has since changed, render fully fresh
5146
+ // rather than risk splicing in HTML for different content.
5147
+ const cacheGeneration = this.previewMemoMatchesSnapshot(snapshot) ? this.previewGeneration : null;
5148
+ let html = await this.renderFullDocumentHtml(snapshot, context, abort.signal, cacheGeneration, (done, total) => updateProgress(done, total, 'Rendering the document'));
5149
+ if (this.assetSession) {
5150
+ html = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, abort.signal, (done, total) => {
5151
+ imageCount = total;
5152
+ updateProgress(done, total, 'Embedding images');
5153
+ });
5154
+ }
5155
+ finalHtml = html;
5156
+ }
5157
+ catch (error) {
5158
+ clearTimeout(showDialogTimer);
5159
+ if (error?.name === 'AbortError') {
5160
+ // Deliberate cancel — just close, no failure to report.
5161
+ if (dialogShown) {
5162
+ this.copyRenderDialogState = null;
5163
+ this.render();
5164
+ }
5165
+ }
5166
+ else {
5167
+ this.finishCopyNotification(dialogShown, { error: error instanceof Error ? error.message : String(error) });
5168
+ this.options.onFailed?.(error);
5169
+ }
5170
+ return;
5171
+ }
5172
+ clearTimeout(showDialogTimer);
5173
+ const scratch = this.elPreviewContent.ownerDocument.createElement('div');
5174
+ scratch.innerHTML = finalHtml;
5175
+ const plainText = scratch.textContent ?? '';
5176
+ if (dialogShown) {
5177
+ // Same reasoning as copyAllPreviewContent: the prepare phase (often
5178
+ // the slower of the two here, once image embedding is involved) has
5179
+ // long since burned through the click's user activation.
5180
+ this.armCopyReady(() => this.writeRichClipboard(finalHtml, plainText, imageCount));
5181
+ return;
5182
+ }
5183
+ const outcome = await this.writeRichClipboard(finalHtml, plainText, imageCount);
5184
+ this.finishCopyNotification(false, outcome);
5185
+ }
5186
+ /**
5187
+ * Writes an HTML+plain-text `ClipboardItem` when the browser supports it,
5188
+ * falling back to a plain-text-only `writeText` (same as
5189
+ * `copyPreviewSelection`, which reports itself as "Plain text") when it
5190
+ * doesn't, or if the rich write itself throws (e.g. a payload too large
5191
+ * for the OS clipboard) — either way the user ends up with *something* on
5192
+ * their clipboard rather than nothing. Returns the confirmation message
5193
+ * for the caller to show (see `finishCopyNotification`), naming the
5194
+ * actual MIME type that ended up on the clipboard ("HTML" for a
5195
+ * successful rich write, "Plain text" for the fallback) so the user knows
5196
+ * what they're about to paste — `imageCount` only adds to the "HTML"
5197
+ * wording, since even a rich write with zero images is still HTML, not
5198
+ * plain text.
5199
+ */
5200
+ async writeRichClipboard(html, plainText, imageCount) {
5201
+ const clipboard = this.editorClipboard();
5202
+ const view = this.elPreviewContent.ownerDocument.defaultView;
5203
+ const ClipboardItemCtor = view?.ClipboardItem;
5204
+ if (clipboard && ClipboardItemCtor && 'write' in clipboard) {
5205
+ try {
5206
+ const item = new ClipboardItemCtor({
5207
+ 'text/html': new Blob([html], { type: 'text/html' }),
5208
+ 'text/plain': new Blob([plainText], { type: 'text/plain' })
5209
+ });
5210
+ // A document with thousands of repeated images (e.g. an avatar
5211
+ // reused across every row of a chat export) can build a payload
5212
+ // well over 100MB, since each occurrence embeds its own full copy
5213
+ // — the clipboard format has no way to reference a shared resource.
5214
+ // 30s is generous for a legitimate slow write; the real purpose is
5215
+ // making sure this can't hang forever with the dialog stuck mid-copy.
5216
+ await this.withTimeout(clipboard.write([item]), this.clipboardWriteTimeoutMs, 'Clipboard write timed out.');
5217
+ return {
5218
+ message: imageCount > 0
5219
+ ? `HTML with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
5220
+ : 'HTML copied to clipboard'
5221
+ };
5222
+ }
5223
+ catch (error) {
5224
+ this.options.onFailed?.(error);
5225
+ // Fall through to the plain-text fallback rather than surfacing
5226
+ // this error directly — if that also fails, its error is more
5227
+ // relevant (it's the one actually blocking the user from getting
5228
+ // anything at all), and the plain-text path already reports this
5229
+ // one to onFailed for anyone watching that.
5230
+ }
5231
+ }
5232
+ return await this.copyPreviewSelection(plainText);
5233
+ }
5234
+ /**
5235
+ * Mounts every chunk from `state.cursor` onward, yielding to a fresh
5236
+ * animation frame between batches (unlike the sentinel path's rAF-time-
5237
+ * budgeted-per-batch loop, this has no viewport to wait on — it has to
5238
+ * plow through the whole rest of the document, so the explicit yield is
5239
+ * what keeps a huge draw from locking up the tab while it does). Stops
5240
+ * early if `signal` aborts, a newer render generation supersedes this one,
5241
+ * or the chunk state's own render context aborts.
5242
+ *
5243
+ * Deliberately uses `renderAndMountChunkBatch` instead of `mountChunkBatch`
5244
+ * and defers image hydration until the whole drain finishes, running it
5245
+ * once over every image the drain mounted, instead of once per batch:
5246
+ * Copy All only needs the mounted chunks' *text* (`elPreviewContent
5247
+ * .textContent`, which images never contribute to), so there's no reason
5248
+ * to wait on `hydrateImages`' async image resolution at all here — and
5249
+ * racing through batches as fast as this loop can while each one fires
5250
+ * its own independent, un-awaited `hydrateImages` background loop (as
5251
+ * `mountChunkBatch` does for the scroll-paced callers, where that's fine —
5252
+ * scrolling naturally rate-limits how many can ever be in flight at once)
5253
+ * lets dozens of those loops pile up concurrently on an image-heavy
5254
+ * document, each spending its own `CHUNK_BUDGET_MS` in the same frame —
5255
+ * measured 100-200ms frame gaps on a 15,000-image document. One combined
5256
+ * pass over every image at the end avoids the pile-up entirely, without
5257
+ * making Copy All wait on it.
5258
+ */
5259
+ async drainRemainingChunks(state, onProgress, signal) {
5260
+ const { records, context, generation, animateImageHydration } = state;
5261
+ // Draining is instead of the scroll-driven continuation, not alongside
5262
+ // it — tearing down any armed sentinel first stops the two from racing
5263
+ // and double-mounting the same chunk.
5264
+ state.sentinelHandle?.destroy();
5265
+ if (this.chunkedRenderState?.generation === generation) {
5266
+ this.chunkedRenderState.sentinelHandle = null;
5267
+ // Same race the `mounting` field guards against elsewhere: an edit
5268
+ // landing mid-drain must not reconcile against a cursor this loop is
5269
+ // actively advancing out from under it.
5270
+ this.chunkedRenderState.mounting = true;
5271
+ }
5272
+ const allPending = [];
5273
+ try {
5274
+ while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < records.length) {
5275
+ if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
5276
+ return;
5277
+ const cursor = this.chunkedRenderState.cursor;
5278
+ const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursor, context, generation);
5279
+ if (generation !== this.previewGeneration || context.signal.aborted)
5280
+ return;
5281
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
5282
+ this.recordChunkProgress(generation, records, newCursor);
5283
+ onProgress(newCursor, records.length);
5284
+ if (signal.aborted)
5285
+ return;
5286
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
5287
+ }
5288
+ }
5289
+ finally {
5290
+ if (this.chunkedRenderState?.generation === generation) {
5291
+ this.chunkedRenderState.mounting = false;
5292
+ }
5293
+ if (generation === this.previewGeneration && !context.signal.aborted) {
5294
+ this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
5295
+ }
5296
+ }
3656
5297
  }
3657
5298
  // Inserts a fenced code block, carrying the chosen language as the fence info
3658
5299
  // string. An empty language produces a plain ```` ``` ```` block.
@@ -4619,6 +6260,12 @@ export class MdzipWorkspaceView {
4619
6260
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
4620
6261
  return;
4621
6262
  }
6263
+ if (performance.now() - this.lastPreviewGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6264
+ return;
6265
+ }
6266
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6267
+ return;
6268
+ }
4622
6269
  const currentTop = this.elPreviewPane.scrollTop;
4623
6270
  // Recognize this event as the echo of our own prior write (from
4624
6271
  // syncScrollToPreview) by comparing values instead of racing timing.
@@ -4649,6 +6296,12 @@ export class MdzipWorkspaceView {
4649
6296
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
4650
6297
  return;
4651
6298
  }
6299
+ if (performance.now() - this.lastEditorGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6300
+ return;
6301
+ }
6302
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6303
+ return;
6304
+ }
4652
6305
  const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
4653
6306
  if (!cmScroller) {
4654
6307
  return;
@@ -4658,8 +6311,44 @@ export class MdzipWorkspaceView {
4658
6311
  if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
4659
6312
  return;
4660
6313
  }
4661
- this.syncing = true;
4662
6314
  const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
6315
+ // The editor is at its true bottom, but under progressive rendering the
6316
+ // preview's scrollHeight only accounts for chunks mounted so far — a
6317
+ // plain ratio jump would only reach the bottom of whatever happens to be
6318
+ // mounted right now, not the document's actual end. Force-drain the rest
6319
+ // first so this edge is reliable regardless of how much has been
6320
+ // scrolled-into-view on the preview side.
6321
+ //
6322
+ // "At the bottom" is detected via CodeMirror's own viewport, not a
6323
+ // scrollHeight/scrollTop pixel comparison: CodeMirror estimates the
6324
+ // height of not-yet-measured (virtualized) lines, and on a huge document
6325
+ // that estimate can be tens of pixels off from where it actually clamps
6326
+ // scrollTop — confirmed live against a real 88,000-line file, where a
6327
+ // small fixed pixel epsilon never matched. `viewport.to` is what
6328
+ // CodeMirror has actually decided to draw for the current scroll
6329
+ // position, so comparing it to the document length is exact regardless
6330
+ // of any height estimation drift.
6331
+ //
6332
+ // `viewport.to >= doc.length` alone is a false positive whenever the
6333
+ // whole document already fits within the editor's visible area — there
6334
+ // is no scrolling at all in that case, so `viewport.to` trivially covers
6335
+ // the full document from position 0 regardless of scroll intent. Without
6336
+ // the overflow check below, a short-to-medium document (especially one
6337
+ // with images, which slow down per-chunk mounting enough to leave a real
6338
+ // window where `cursor < records.length`) could hit this branch off of
6339
+ // an incidental scroll event fired mid-edit — e.g. focus or cursor
6340
+ // movement nudging `scrollTop` by a sub-pixel amount — and force-drain
6341
+ // plus jump the preview to its bottom for no reason the user asked for,
6342
+ // which (via the echo-breaking reflow that follows, since late-arriving
6343
+ // image layout shifts the "bottom" after the jump) can drag the editor's
6344
+ // own scroll down to match through `syncScrollFromPreview`'s echo path.
6345
+ const hasScrollableOverflow = editorHeight > 0;
6346
+ const atDocEnd = hasScrollableOverflow && this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
6347
+ if (atDocEnd && this.chunkedRenderState && this.chunkedRenderState.cursor < this.chunkedRenderState.records.length) {
6348
+ void this.syncScrollToPreviewBottom();
6349
+ return;
6350
+ }
6351
+ this.syncing = true;
4663
6352
  const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
4664
6353
  const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
4665
6354
  const target = scrollRatio * previewHeight;
@@ -4667,6 +6356,84 @@ export class MdzipWorkspaceView {
4667
6356
  this.elPreviewPane.scrollTop = target;
4668
6357
  this.syncing = false;
4669
6358
  }
6359
+ /**
6360
+ * Handles syncScrollToPreview's bottom edge when the preview still has
6361
+ * unmounted chunks: force-mounts the rest (same drain Copy All uses) so
6362
+ * the preview's scrollHeight reflects the whole document, then jumps to
6363
+ * its real bottom — instead of the ordinary ratio-based jump, which would
6364
+ * only land at the bottom of whatever was mounted the instant the sync
6365
+ * fired. Re-checks that the editor is still at its bottom and the document
6366
+ * hasn't changed before applying the jump, since the drain can take long
6367
+ * enough on a huge document for either to no longer hold.
6368
+ *
6369
+ * On the most extreme real documents this drain has been measured at
6370
+ * ~106s (thousands of chunks, tens of thousands of images each needing a
6371
+ * real DOM slot + IntersectionObserver registration) — long enough that a
6372
+ * silent wait looks indistinguishable from a frozen preview pane. Past a
6373
+ * short debounce (so ordinary documents never see it), a small status
6374
+ * toast shows progress, reusing the same element Copy All's confirmation
6375
+ * messages use.
6376
+ */
6377
+ async syncScrollToPreviewBottom() {
6378
+ const state = this.chunkedRenderState;
6379
+ if (!state || this.bottomDrainGeneration === state.generation) {
6380
+ return;
6381
+ }
6382
+ const generation = state.generation;
6383
+ this.bottomDrainGeneration = generation;
6384
+ if (this.copyToastHideTimer) {
6385
+ clearTimeout(this.copyToastHideTimer);
6386
+ this.copyToastHideTimer = null;
6387
+ }
6388
+ let showToastTimer = setTimeout(() => {
6389
+ showToastTimer = null;
6390
+ this.scrollCatchUpState = { done: state.cursor, total: state.records.length };
6391
+ this.updateScrollCatchUpToast();
6392
+ }, 200);
6393
+ try {
6394
+ await this.drainRemainingChunks(state, (done, total) => {
6395
+ if (!this.scrollCatchUpState)
6396
+ return;
6397
+ this.scrollCatchUpState = { done, total };
6398
+ this.updateScrollCatchUpToast();
6399
+ }, new AbortController().signal);
6400
+ }
6401
+ finally {
6402
+ if (showToastTimer) {
6403
+ clearTimeout(showToastTimer);
6404
+ }
6405
+ if (this.scrollCatchUpState) {
6406
+ this.scrollCatchUpState = null;
6407
+ this.elCopyToast.hidden = true;
6408
+ }
6409
+ if (this.bottomDrainGeneration === generation) {
6410
+ this.bottomDrainGeneration = null;
6411
+ }
6412
+ }
6413
+ if (generation !== this.previewGeneration || this.layout !== 'split' || !this.cmEditor) {
6414
+ return;
6415
+ }
6416
+ // See the comment in syncScrollToPreview on why this is viewport-based
6417
+ // rather than a scrollHeight/scrollTop pixel comparison.
6418
+ if (this.cmEditor.viewport.to < this.cmEditor.state.doc.length) {
6419
+ return;
6420
+ }
6421
+ this.syncing = true;
6422
+ const target = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
6423
+ this.lastSyncedPreviewScrollTop = target;
6424
+ this.elPreviewPane.scrollTop = target;
6425
+ this.syncing = false;
6426
+ }
6427
+ /** Syncs the catch-up toast's text from `scrollCatchUpState`, bypassing `render()` — see `updateCopyRenderDialogProgress` for why. No-op while the toast isn't showing. */
6428
+ updateScrollCatchUpToast() {
6429
+ if (!this.scrollCatchUpState) {
6430
+ return;
6431
+ }
6432
+ const { done, total } = this.scrollCatchUpState;
6433
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
6434
+ this.elCopyToast.textContent = total > 0 ? `Catching up the preview... (${percent}%)` : 'Catching up the preview...';
6435
+ this.elCopyToast.hidden = false;
6436
+ }
4670
6437
  }
4671
6438
  function canShowSourceLayout(snapshot) {
4672
6439
  return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
@@ -4791,8 +6558,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
4791
6558
  : after.startsWith('\n') ? '\n' : '\n\n';
4792
6559
  return `${prefix}${html}${suffix}`;
4793
6560
  }
4794
- function escapeMarkdownImageAlt(value) {
4795
- return value.replace(/\\/g, '\\\\').replace(/]/g, '\\]');
6561
+ function formatByteSize(bytes) {
6562
+ if (!Number.isFinite(bytes) || bytes < 0) {
6563
+ return 'Not available';
6564
+ }
6565
+ if (bytes < 1024) {
6566
+ return `${bytes} B`;
6567
+ }
6568
+ const units = ['KB', 'MB', 'GB'];
6569
+ let value = bytes / 1024;
6570
+ let unitIndex = 0;
6571
+ while (value >= 1024 && unitIndex < units.length - 1) {
6572
+ value /= 1024;
6573
+ unitIndex += 1;
6574
+ }
6575
+ // One decimal below 10 of a unit (e.g. "1.4 MB"), none above (e.g. "23 MB") —
6576
+ // matches how OS file managers commonly round these.
6577
+ const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString();
6578
+ return `${formatted} ${units[unitIndex]}`;
4796
6579
  }
4797
6580
  function formatMetadataValue(value) {
4798
6581
  if (typeof value === 'string' && value.trim()) {
@@ -4804,299 +6587,325 @@ function formatMetadataValue(value) {
4804
6587
  }
4805
6588
  return 'Not available';
4806
6589
  }
4807
- const SHELL_HTML = `
4808
- <section class="mdzip-root">
4809
- <div class="document-strip" data-ref="document-strip" hidden>
4810
- <button type="button" class="title-button" data-ref="title-btn"></button>
4811
- <button type="button" class="document-info-button" data-ref="document-info-btn"
4812
- title="Document information" aria-label="Document information">
4813
- ${INFO_ICON_HTML}
4814
- </button>
4815
- </div>
4816
-
4817
- <header class="toolbar" data-ref="toolbar" hidden>
4818
- <div class="toolbar-start">
4819
- <div class="toolbar-left">
4820
- <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
4821
- ${NAV_TOGGLE_ICON_HTML}
4822
- </button>
4823
- </div>
4824
-
4825
- <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
4826
- <div class="edit-toolbar-group">
4827
- <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
4828
- <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
4829
- <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
4830
- <div class="format-menu" data-format-control="headings">
4831
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
4832
- aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
4833
- ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
4834
- </button>
4835
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
4836
- <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
4837
- <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
4838
- <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
4839
- <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
4840
- <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
4841
- <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
4842
- <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
4843
- </div>
4844
- </div>
4845
- </div>
4846
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
4847
- <div class="edit-toolbar-group">
4848
- <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
4849
- <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
4850
- <div class="format-menu" data-format-control="code">
4851
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
4852
- aria-label="Code" aria-haspopup="menu" aria-expanded="false">
4853
- ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
4854
- </button>
4855
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
4856
- <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
4857
- <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
4858
- </div>
4859
- </div>
4860
- <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
4861
- <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
4862
- </div>
4863
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
4864
- <div class="edit-toolbar-group">
4865
- <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
4866
- <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
4867
- <input type="file" data-ref="image-input" accept="image/*" hidden />
4868
- </div>
4869
- </div>
4870
- </div>
4871
-
4872
- <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
4873
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
4874
- <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
4875
- </button>
4876
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
4877
- <span class="commandbar-flex-container">
4878
- ${SPLIT_ICON_HTML}
4879
- </span>
4880
- </button>
4881
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
4882
- <span class="commandbar-flex-container">
4883
- ${PREVIEW_ICON_HTML}
4884
- </span>
4885
- </button>
4886
- </div>
4887
-
4888
- <div class="toolbar-controls" data-ref="toolbar-controls">
4889
- <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
4890
- ${SEARCH_ICON_HTML}
4891
- </button>
4892
- <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
4893
- ${SAVE_ICON_HTML}
4894
- </button>
4895
- <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
4896
- ${ZOOM_ICON_HTML}
4897
- </button>
4898
- <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
4899
- <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
4900
- title="Dark mode" aria-label="Dark mode" aria-pressed="false">
4901
- ${DARK_THEME_ICON_HTML}
4902
- </button>
4903
- <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
4904
- title="Light mode" aria-label="Light mode" aria-pressed="false">
4905
- ${LIGHT_THEME_ICON_HTML}
4906
- </button>
4907
- </div>
4908
- <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
4909
- <span class="zoom-level" data-ref="zoom-level">100%</span>
4910
- <span class="zoom-stepper">
4911
- <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
4912
- <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
4913
- </span>
4914
- <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
4915
- </div>
4916
- </div>
4917
- </header>
4918
-
4919
- <div class="workspace-shell" data-ref="workspace-shell" hidden>
4920
- <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
4921
- <div class="nav-tree" data-ref="nav-tree"></div>
4922
- </aside>
4923
- <div class="nav-resizer" data-ref="nav-resizer"
4924
- role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
4925
-
4926
- <div class="pane-stack" data-ref="pane-stack">
4927
- <section class="pane edit-pane" data-ref="edit-pane">
4928
- <div class="editor-host" data-ref="editor-host"></div>
4929
- </section>
4930
- <div class="split-resizer" data-ref="split-resizer"
4931
- role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
4932
- <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
4933
- <article class="preview-content" data-ref="preview-content"></article>
4934
- </section>
4935
- <section class="pane entry-pane" data-ref="entry-pane"></section>
4936
- </div>
4937
- </div>
4938
-
4939
- <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
4940
- role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
4941
- <div class="title-dialog">
4942
- <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
4943
- <p>This is the package-level title stored in manifest.json.</p>
4944
- <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
4945
- <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
4946
- <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
4947
- <div class="title-dialog-actions">
4948
- <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
4949
- <button type="button" data-action="cancel-title">Cancel</button>
4950
- <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
4951
- </div>
4952
- </div>
4953
- </div>
4954
-
4955
- <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
4956
- role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
4957
- <div class="title-dialog">
4958
- <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
4959
- <p>
4960
- Regular Markdown files contain only text. Convert this document to an
4961
- MDZ package to add images, package navigation, and document metadata.
4962
- </p>
4963
- <p>The next save will produce an .mdz file.</p>
4964
- <div class="title-dialog-actions">
4965
- <button type="button" data-action="cancel-conversion">Cancel</button>
4966
- <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
4967
- </div>
4968
- </div>
4969
- </div>
4970
-
4971
- <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
4972
- role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
4973
- <div class="title-dialog image-insert-dialog">
4974
- <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
4975
- <p>Choose the Markdown inserted for this image.</p>
4976
- <fieldset class="image-insert-options">
4977
- <legend>Output</legend>
4978
- <label>
4979
- <input type="radio" name="mdzip-image-insert-mode" value="markdown"
4980
- data-ref="image-insert-mode-markdown" checked />
4981
- Markdown image
4982
- </label>
4983
- <label>
4984
- <input type="radio" name="mdzip-image-insert-mode" value="html"
4985
- data-ref="image-insert-mode-html" />
4986
- HTML &lt;img&gt; with sizing
4987
- </label>
4988
- </fieldset>
4989
- <label class="image-insert-field">
4990
- <span>Alt text</span>
4991
- <input type="text" data-ref="image-insert-alt" />
4992
- </label>
4993
- <div class="image-insert-grid">
4994
- <label class="image-insert-field">
4995
- <span>Size by</span>
4996
- <select data-ref="image-insert-size-mode">
4997
- <option value="original">Original size</option>
4998
- <option value="width" selected>Width</option>
4999
- <option value="height">Height</option>
5000
- <option value="percent">Percent</option>
5001
- </select>
5002
- </label>
5003
- <label class="image-insert-field">
5004
- <span>Value</span>
5005
- <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
5006
- </label>
5007
- </div>
5008
- <label class="image-insert-field">
5009
- <span>Position</span>
5010
- <select data-ref="image-insert-position">
5011
- <option value="inline">Inline/default</option>
5012
- <option value="left">Left</option>
5013
- <option value="center">Center</option>
5014
- <option value="right">Right</option>
5015
- <option value="wrap-left">Wrap left</option>
5016
- <option value="wrap-right">Wrap right</option>
5017
- </select>
5018
- </label>
5019
- <div class="title-dialog-actions">
5020
- <button type="button" data-action="cancel-image-insert">Cancel</button>
5021
- <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
5022
- </div>
5023
- </div>
5024
- </div>
5025
-
5026
- <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
5027
- role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
5028
- <div class="title-dialog pack-files-dialog">
5029
- <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
5030
- <p>These files contain more than one Markdown document. Choose how to package them.</p>
5031
- <fieldset class="pack-files-options">
5032
- <legend>Mode</legend>
5033
- <label>
5034
- <input type="radio" name="mdzip-pack-files-mode" value="document"
5035
- data-ref="pack-files-mode-document" checked />
5036
- Document — one primary page; the rest are attached
5037
- </label>
5038
- <label>
5039
- <input type="radio" name="mdzip-pack-files-mode" value="project"
5040
- data-ref="pack-files-mode-project" />
5041
- Project — a set of related documents
5042
- </label>
5043
- </fieldset>
5044
- <label class="pack-files-field">
5045
- <span>Entry point</span>
5046
- <select data-ref="pack-files-entry"></select>
5047
- </label>
5048
- <div class="title-dialog-actions">
5049
- <button type="button" data-action="cancel-pack-files">Cancel</button>
5050
- <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
5051
- </div>
5052
- </div>
5053
- </div>
5054
-
5055
- <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
5056
- role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
5057
- <div class="title-dialog metadata-dialog">
5058
- <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
5059
- <dl data-ref="metadata-list"></dl>
5060
- <h4>Libraries</h4>
5061
- <dl data-ref="library-list"></dl>
5062
- <div class="title-dialog-actions">
5063
- <button type="button" class="save-title" data-action="close-metadata">Close</button>
5064
- </div>
5065
- </div>
5066
- </div>
5067
-
5068
- <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
5069
- role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
5070
- <div class="title-dialog">
5071
- <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
5072
- <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
5073
- <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
5074
- <div class="title-dialog-actions">
5075
- <button type="button" data-action="cancel-name">Cancel</button>
5076
- <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
5077
- </div>
5078
- </div>
5079
- </div>
5080
-
5081
- <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
5082
- role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
5083
- <div class="title-dialog">
5084
- <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
5085
- <p data-ref="delete-dialog-text"></p>
5086
- <div class="title-dialog-actions">
5087
- <button type="button" data-action="cancel-delete">Cancel</button>
5088
- <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
5089
- </div>
5090
- </div>
5091
- </div>
5092
-
5093
- <input type="file" data-ref="replace-input" hidden />
5094
-
5095
- <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
5096
-
5097
- <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
5098
-
5099
- <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
5100
- </section>
6590
+ const SHELL_HTML = `
6591
+ <section class="mdzip-root">
6592
+ <div class="document-strip" data-ref="document-strip" hidden>
6593
+ <button type="button" class="title-button" data-ref="title-btn"></button>
6594
+ <button type="button" class="document-info-button" data-ref="document-info-btn"
6595
+ title="Document information" aria-label="Document information">
6596
+ ${INFO_ICON_HTML}
6597
+ </button>
6598
+ </div>
6599
+
6600
+ <header class="toolbar" data-ref="toolbar" hidden>
6601
+ <div class="toolbar-start">
6602
+ <div class="toolbar-left">
6603
+ <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
6604
+ ${NAV_TOGGLE_ICON_HTML}
6605
+ </button>
6606
+ </div>
6607
+
6608
+ <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
6609
+ <div class="edit-toolbar-group">
6610
+ <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
6611
+ <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
6612
+ <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
6613
+ <div class="format-menu" data-format-control="headings">
6614
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
6615
+ aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
6616
+ ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
6617
+ </button>
6618
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
6619
+ <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
6620
+ <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
6621
+ <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
6622
+ <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
6623
+ <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
6624
+ <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
6625
+ <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
6626
+ </div>
6627
+ </div>
6628
+ </div>
6629
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
6630
+ <div class="edit-toolbar-group">
6631
+ <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
6632
+ <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
6633
+ <div class="format-menu" data-format-control="code">
6634
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
6635
+ aria-label="Code" aria-haspopup="menu" aria-expanded="false">
6636
+ ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
6637
+ </button>
6638
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
6639
+ <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
6640
+ <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
6641
+ </div>
6642
+ </div>
6643
+ <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
6644
+ <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
6645
+ </div>
6646
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
6647
+ <div class="edit-toolbar-group">
6648
+ <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
6649
+ <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
6650
+ <input type="file" data-ref="image-input" accept="image/*" hidden />
6651
+ </div>
6652
+ </div>
6653
+ </div>
6654
+
6655
+ <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
6656
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
6657
+ <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
6658
+ </button>
6659
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
6660
+ <span class="commandbar-flex-container">
6661
+ ${SPLIT_ICON_HTML}
6662
+ </span>
6663
+ </button>
6664
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
6665
+ <span class="commandbar-flex-container">
6666
+ ${PREVIEW_ICON_HTML}
6667
+ </span>
6668
+ </button>
6669
+ </div>
6670
+
6671
+ <div class="toolbar-controls" data-ref="toolbar-controls">
6672
+ <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
6673
+ ${SEARCH_ICON_HTML}
6674
+ </button>
6675
+ <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
6676
+ ${SAVE_ICON_HTML}
6677
+ </button>
6678
+ <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
6679
+ ${ZOOM_ICON_HTML}
6680
+ </button>
6681
+ <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
6682
+ <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
6683
+ title="Dark mode" aria-label="Dark mode" aria-pressed="false">
6684
+ ${DARK_THEME_ICON_HTML}
6685
+ </button>
6686
+ <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
6687
+ title="Light mode" aria-label="Light mode" aria-pressed="false">
6688
+ ${LIGHT_THEME_ICON_HTML}
6689
+ </button>
6690
+ </div>
6691
+ <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
6692
+ <span class="zoom-level" data-ref="zoom-level">100%</span>
6693
+ <span class="zoom-stepper">
6694
+ <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
6695
+ <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
6696
+ </span>
6697
+ <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
6698
+ </div>
6699
+ </div>
6700
+ </header>
6701
+
6702
+ <div class="workspace-shell" data-ref="workspace-shell" hidden>
6703
+ <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
6704
+ <div class="nav-tree" data-ref="nav-tree"></div>
6705
+ </aside>
6706
+ <div class="nav-resizer" data-ref="nav-resizer"
6707
+ role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
6708
+
6709
+ <div class="pane-stack" data-ref="pane-stack">
6710
+ <section class="pane edit-pane" data-ref="edit-pane">
6711
+ <div class="editor-host" data-ref="editor-host"></div>
6712
+ </section>
6713
+ <div class="split-resizer" data-ref="split-resizer"
6714
+ role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
6715
+ <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
6716
+ <article class="preview-content" data-ref="preview-content"></article>
6717
+ </section>
6718
+ <section class="pane entry-pane" data-ref="entry-pane"></section>
6719
+ </div>
6720
+ </div>
6721
+
6722
+ <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
6723
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
6724
+ <div class="title-dialog">
6725
+ <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
6726
+ <p>This is the package-level title stored in manifest.json.</p>
6727
+ <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
6728
+ <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
6729
+ <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
6730
+ <div class="title-dialog-actions">
6731
+ <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
6732
+ <button type="button" data-action="cancel-title">Cancel</button>
6733
+ <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
6734
+ </div>
6735
+ </div>
6736
+ </div>
6737
+
6738
+ <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
6739
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
6740
+ <div class="title-dialog">
6741
+ <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
6742
+ <p>
6743
+ Regular Markdown files contain only text. Convert this document to an
6744
+ MDZ package to add images, package navigation, and document metadata.
6745
+ </p>
6746
+ <p>The next save will produce an .mdz file.</p>
6747
+ <div class="title-dialog-actions">
6748
+ <button type="button" data-action="cancel-conversion">Cancel</button>
6749
+ <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
6750
+ </div>
6751
+ </div>
6752
+ </div>
6753
+
6754
+ <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
6755
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
6756
+ <div class="title-dialog image-insert-dialog">
6757
+ <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
6758
+ <p>Choose the Markdown inserted for this image.</p>
6759
+ <fieldset class="image-insert-options">
6760
+ <legend>Output</legend>
6761
+ <label>
6762
+ <input type="radio" name="mdzip-image-insert-mode" value="markdown"
6763
+ data-ref="image-insert-mode-markdown" checked />
6764
+ Markdown image
6765
+ </label>
6766
+ <label>
6767
+ <input type="radio" name="mdzip-image-insert-mode" value="html"
6768
+ data-ref="image-insert-mode-html" />
6769
+ HTML &lt;img&gt; with sizing
6770
+ </label>
6771
+ </fieldset>
6772
+ <label class="image-insert-field">
6773
+ <span>Alt text</span>
6774
+ <input type="text" data-ref="image-insert-alt" />
6775
+ </label>
6776
+ <div class="image-insert-grid">
6777
+ <label class="image-insert-field">
6778
+ <span>Size by</span>
6779
+ <select data-ref="image-insert-size-mode">
6780
+ <option value="original">Original size</option>
6781
+ <option value="width" selected>Width</option>
6782
+ <option value="height">Height</option>
6783
+ <option value="percent">Percent</option>
6784
+ </select>
6785
+ </label>
6786
+ <label class="image-insert-field">
6787
+ <span>Value</span>
6788
+ <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
6789
+ </label>
6790
+ </div>
6791
+ <label class="image-insert-field">
6792
+ <span>Position</span>
6793
+ <select data-ref="image-insert-position">
6794
+ <option value="inline">Inline/default</option>
6795
+ <option value="left">Left</option>
6796
+ <option value="center">Center</option>
6797
+ <option value="right">Right</option>
6798
+ <option value="wrap-left">Wrap left</option>
6799
+ <option value="wrap-right">Wrap right</option>
6800
+ </select>
6801
+ </label>
6802
+ <div class="title-dialog-actions">
6803
+ <button type="button" data-action="cancel-image-insert">Cancel</button>
6804
+ <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
6805
+ </div>
6806
+ </div>
6807
+ </div>
6808
+
6809
+ <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
6810
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
6811
+ <div class="title-dialog pack-files-dialog">
6812
+ <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
6813
+ <p>These files contain more than one Markdown document. Choose how to package them.</p>
6814
+ <fieldset class="pack-files-options">
6815
+ <legend>Mode</legend>
6816
+ <label>
6817
+ <input type="radio" name="mdzip-pack-files-mode" value="document"
6818
+ data-ref="pack-files-mode-document" checked />
6819
+ Document — one primary page; the rest are attached
6820
+ </label>
6821
+ <label>
6822
+ <input type="radio" name="mdzip-pack-files-mode" value="project"
6823
+ data-ref="pack-files-mode-project" />
6824
+ Project — a set of related documents
6825
+ </label>
6826
+ </fieldset>
6827
+ <label class="pack-files-field">
6828
+ <span>Entry point</span>
6829
+ <select data-ref="pack-files-entry"></select>
6830
+ </label>
6831
+ <div class="title-dialog-actions">
6832
+ <button type="button" data-action="cancel-pack-files">Cancel</button>
6833
+ <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
6834
+ </div>
6835
+ </div>
6836
+ </div>
6837
+
6838
+ <div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
6839
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
6840
+ <div class="title-dialog copy-render-dialog">
6841
+ <h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
6842
+ <div data-ref="copy-render-progress-section">
6843
+ <p data-ref="copy-render-progress-text">Rendering the full document...</p>
6844
+ <div class="copy-render-progress-track">
6845
+ <div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
6846
+ </div>
6847
+ </div>
6848
+ <p data-ref="copy-render-ready-text" hidden>
6849
+ The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
6850
+ click Copy to finish.
6851
+ </p>
6852
+ <p data-ref="copy-render-done-text" hidden></p>
6853
+ <div class="title-dialog-actions">
6854
+ <button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
6855
+ <button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
6856
+ <button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
6857
+ <button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
6858
+ </div>
6859
+ </div>
6860
+ </div>
6861
+
6862
+ <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
6863
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
6864
+ <div class="title-dialog metadata-dialog">
6865
+ <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
6866
+ <dl data-ref="metadata-list"></dl>
6867
+ <h4>Libraries</h4>
6868
+ <dl data-ref="library-list"></dl>
6869
+ <div class="title-dialog-actions">
6870
+ <button type="button" class="save-title" data-action="close-metadata">Close</button>
6871
+ </div>
6872
+ </div>
6873
+ </div>
6874
+
6875
+ <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
6876
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
6877
+ <div class="title-dialog">
6878
+ <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
6879
+ <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
6880
+ <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
6881
+ <div class="title-dialog-actions">
6882
+ <button type="button" data-action="cancel-name">Cancel</button>
6883
+ <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
6884
+ </div>
6885
+ </div>
6886
+ </div>
6887
+
6888
+ <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
6889
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
6890
+ <div class="title-dialog">
6891
+ <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
6892
+ <p data-ref="delete-dialog-text"></p>
6893
+ <div class="title-dialog-actions">
6894
+ <button type="button" data-action="cancel-delete">Cancel</button>
6895
+ <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
6896
+ </div>
6897
+ </div>
6898
+ </div>
6899
+
6900
+ <input type="file" data-ref="replace-input" hidden />
6901
+
6902
+ <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
6903
+
6904
+ <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
6905
+
6906
+ <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
6907
+
6908
+ <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
6909
+ </section>
5101
6910
  `;
5102
6911
  //# sourceMappingURL=view.js.map