@mdzip/editor 1.3.20 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +49 -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 +12 -0
  6. package/dist/asset-cache.d.ts.map +1 -1
  7. package/dist/asset-cache.js +45 -0
  8. package/dist/asset-cache.js.map +1 -1
  9. package/dist/image-edit.d.ts +26 -0
  10. package/dist/image-edit.d.ts.map +1 -0
  11. package/dist/image-edit.js +195 -0
  12. package/dist/image-edit.js.map +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/library-info.d.ts +2 -2
  18. package/dist/library-info.js +2 -2
  19. package/dist/library-info.js.map +1 -1
  20. package/dist/mdz-archive-worker-protocol.d.ts +36 -0
  21. package/dist/mdz-archive-worker-protocol.d.ts.map +1 -0
  22. package/dist/mdz-archive-worker-protocol.js +2 -0
  23. package/dist/mdz-archive-worker-protocol.js.map +1 -0
  24. package/dist/mdz-archive.worker-client.d.ts +30 -0
  25. package/dist/mdz-archive.worker-client.d.ts.map +1 -0
  26. package/dist/mdz-archive.worker-client.js +97 -0
  27. package/dist/mdz-archive.worker-client.js.map +1 -0
  28. package/dist/mdz-archive.worker.d.ts +2 -0
  29. package/dist/mdz-archive.worker.d.ts.map +1 -0
  30. package/dist/mdz-archive.worker.js +8870 -0
  31. package/dist/mdz-archive.worker.js.map +1 -0
  32. package/dist/rendering.d.ts +44 -0
  33. package/dist/rendering.d.ts.map +1 -1
  34. package/dist/rendering.js +92 -0
  35. package/dist/rendering.js.map +1 -1
  36. package/dist/stats.d.ts +23 -0
  37. package/dist/stats.d.ts.map +1 -0
  38. package/dist/stats.js +31 -0
  39. package/dist/stats.js.map +1 -0
  40. package/dist/view-css.d.ts.map +1 -1
  41. package/dist/view-css.js +46 -1
  42. package/dist/view-css.js.map +1 -1
  43. package/dist/view.d.ts +342 -1
  44. package/dist/view.d.ts.map +1 -1
  45. package/dist/view.js +1425 -99
  46. package/dist/view.js.map +1 -1
  47. package/dist/workspace.d.ts +14 -0
  48. package/dist/workspace.d.ts.map +1 -1
  49. package/dist/workspace.js +53 -41
  50. package/dist/workspace.js.map +1 -1
  51. package/package.json +9 -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,8 @@ 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 } from './rendering.js';
17
18
  import { WORKSPACE_CSS } from './view-css.js';
18
19
  const STYLE_ATTR = 'data-mdzip-ws-styles';
19
20
  const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
@@ -31,6 +32,7 @@ const IMAGE_ICON_HTML = lucideIcon(FileImage, NAV_ICON_CLASS);
31
32
  const FILE_ICON_HTML = lucideIcon(File, NAV_ICON_CLASS);
32
33
  const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
33
34
  const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
35
+ const IMAGE_EDIT_AFFORDANCE_ICON_HTML = lucideIcon(SquarePen, 'mdzip-image-edit-affordance-icon');
34
36
  const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
35
37
  const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
36
38
  const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS} convert-mdz-icon`);
@@ -147,6 +149,16 @@ function renderContextMenuItems(items) {
147
149
  .map((item) => (item === null ? MENU_SEPARATOR_HTML : renderContextMenuItem(item)))
148
150
  .join('');
149
151
  }
152
+ const PREVIEW_MAX_WIDTH_ALIASES = {
153
+ narrow: 650,
154
+ default: 900,
155
+ wide: 1200,
156
+ };
157
+ function resolvePreviewMaxWidthPx(value) {
158
+ if (value === undefined)
159
+ return undefined;
160
+ return typeof value === 'number' ? value : PREVIEW_MAX_WIDTH_ALIASES[value];
161
+ }
150
162
  const ALL_HEADINGS = [1, 2, 3, 4, 5, 6];
151
163
  const ALL_LAYOUT_CONTROLS = {
152
164
  source: true,
@@ -380,21 +392,29 @@ const mdzipEditorTheme = EditorView.theme({
380
392
  overflow: 'auto',
381
393
  },
382
394
  '.cm-content': {
383
- padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px))',
395
+ padding: 'var(--mdzip-editor-content-padding, var(--mdzip-density-editor-content-padding, 36px 48px 36px 16px))',
384
396
  caretColor: 'var(--mdzip-editor-cursor-color)',
385
397
  overflowWrap: 'anywhere',
386
398
  wordBreak: 'normal',
387
399
  },
388
400
  '.cm-gutters': {
389
- background: 'var(--mdzip-widget-background-color)',
390
- borderRight: '1px solid var(--mdzip-border-color)',
401
+ background: 'transparent',
402
+ border: 'none',
391
403
  color: 'var(--mdzip-line-number-foreground-color)',
392
404
  fontFamily: '"Cascadia Code", Consolas, monospace',
393
- minWidth: '52px',
405
+ fontSize: '0.85em',
406
+ // CodeMirror force-sets each gutter element's height to match its
407
+ // content line's rendered height, but a unitless/inherited line-height
408
+ // resolves against the gutter's own (smaller) font-size — leaving the
409
+ // number shorter than its box and rendering top-aligned instead of
410
+ // matching the content line's baseline. Pin it to the same absolute
411
+ // line-height as the content (fontSize '&' * the '.cm-scroller' 1.5
412
+ // multiplier) so both sit centered in equal-height boxes.
413
+ lineHeight: 'calc(16px * var(--mdz-zoom, 1) * 1.5)',
414
+ opacity: '0.65',
394
415
  },
395
416
  '.cm-lineNumbers .cm-gutterElement': {
396
417
  padding: '0 8px 0 4px',
397
- minWidth: '44px',
398
418
  },
399
419
  '&.cm-focused .cm-cursor': {
400
420
  borderLeftColor: 'var(--mdzip-editor-cursor-color)',
@@ -415,6 +435,29 @@ const mdzipEditorTheme = EditorView.theme({
415
435
  color: 'var(--mdzip-muted-foreground-color)',
416
436
  opacity: '0.65',
417
437
  },
438
+ '.mdzip-image-edit-affordance': {
439
+ display: 'inline-flex',
440
+ alignItems: 'center',
441
+ justifyContent: 'center',
442
+ width: '20px',
443
+ height: '20px',
444
+ marginLeft: '2px',
445
+ padding: '0',
446
+ verticalAlign: 'middle',
447
+ border: 'none',
448
+ borderRadius: '4px',
449
+ background: 'var(--mdzip-widget-background-color)',
450
+ color: 'var(--mdzip-muted-foreground-color)',
451
+ cursor: 'pointer',
452
+ },
453
+ '.mdzip-image-edit-affordance:hover': {
454
+ background: 'var(--mdzip-control-hover-background-color)',
455
+ color: 'var(--mdzip-control-foreground-color)',
456
+ },
457
+ '.mdzip-image-edit-affordance-icon': {
458
+ width: '13px',
459
+ height: '13px',
460
+ },
418
461
  '.cm-panels': {
419
462
  background: 'var(--mdzip-widget-background-color)',
420
463
  color: 'var(--mdzip-editor-foreground-color)',
@@ -537,6 +580,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
537
580
  }, {
538
581
  decorations: value => value.decorations
539
582
  });
583
+ // Fenced/indented code blocks, inline code spans, and link/image URLs aren't
584
+ // prose — the browser's spellchecker has no dictionary for shell syntax or
585
+ // filenames, so it just underlines everything. Syntax-tree node ranges (not
586
+ // a MatchDecorator regexp) are required here because MatchDecorator only
587
+ // matches within a single line, and fenced code blocks span many.
588
+ const NO_SPELLCHECK_NODE_NAMES = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'URL']);
589
+ const noSpellcheckMark = Decoration.mark({ attributes: { spellcheck: 'false' } });
590
+ function buildNoSpellcheckDecorations(view) {
591
+ const builder = new RangeSetBuilder();
592
+ for (const { from, to } of view.visibleRanges) {
593
+ syntaxTree(view.state).iterate({
594
+ from,
595
+ to,
596
+ enter: (node) => {
597
+ if (NO_SPELLCHECK_NODE_NAMES.has(node.name)) {
598
+ builder.add(node.from, node.to, noSpellcheckMark);
599
+ return false;
600
+ }
601
+ return true;
602
+ }
603
+ });
604
+ }
605
+ return builder.finish();
606
+ }
607
+ const noSpellcheckHighlight = ViewPlugin.fromClass(class {
608
+ constructor(view) {
609
+ this.decorations = buildNoSpellcheckDecorations(view);
610
+ }
611
+ update(update) {
612
+ if (update.docChanged || update.viewportChanged || syntaxTree(update.state) !== syntaxTree(update.startState)) {
613
+ this.decorations = buildNoSpellcheckDecorations(update.view);
614
+ }
615
+ }
616
+ }, {
617
+ decorations: value => value.decorations
618
+ });
619
+ // Click-to-edit affordance for existing image references. Populated
620
+ // on-click (not continuously, since the trigger is click not hover) via a
621
+ // StateEffect dispatched from the editor's `click` domEventHandler in
622
+ // createCmEditor. Fully inert unless a host imageEditHandler is set — see
623
+ // that click handler's own gate.
624
+ const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
625
+ const imageEditAffordanceEffect = StateEffect.define();
626
+ class ImageEditAffordanceWidget extends WidgetType {
627
+ constructor(from, to) {
628
+ super();
629
+ this.from = from;
630
+ this.to = to;
631
+ }
632
+ eq(other) {
633
+ return other.from === this.from && other.to === this.to;
634
+ }
635
+ toDOM() {
636
+ const btn = document.createElement('button');
637
+ btn.type = 'button';
638
+ btn.className = 'mdzip-image-edit-affordance';
639
+ btn.setAttribute('aria-label', 'Edit image');
640
+ btn.setAttribute('data-mdzip-image-edit-affordance', '');
641
+ btn.innerHTML = IMAGE_EDIT_AFFORDANCE_ICON_HTML;
642
+ // Buttons are natively focusable; without this, clicking one moves DOM
643
+ // focus onto it and blurs the CodeMirror content — which would clear
644
+ // (and remove from the DOM) this very widget via the blur handler below
645
+ // before its own click event has a chance to fire.
646
+ btn.addEventListener('mousedown', (event) => event.preventDefault());
647
+ btn.addEventListener('click', (event) => {
648
+ event.preventDefault();
649
+ event.stopPropagation();
650
+ btn.dispatchEvent(new CustomEvent(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, {
651
+ bubbles: true,
652
+ detail: { from: this.from, to: this.to }
653
+ }));
654
+ });
655
+ return btn;
656
+ }
657
+ }
658
+ function imageEditAffordanceDeco(from, to) {
659
+ return Decoration.set([Decoration.widget({ widget: new ImageEditAffordanceWidget(from, to), side: 1 }).range(to)]);
660
+ }
661
+ const imageEditAffordanceField = StateField.define({
662
+ create: () => Decoration.none,
663
+ update(value, tr) {
664
+ value = value.map(tr.changes);
665
+ if (tr.docChanged) {
666
+ value = Decoration.none;
667
+ }
668
+ for (const effect of tr.effects) {
669
+ if (effect.is(imageEditAffordanceEffect)) {
670
+ value = effect.value ? imageEditAffordanceDeco(effect.value.from, effect.value.to) : Decoration.none;
671
+ }
672
+ }
673
+ return value;
674
+ },
675
+ provide: (field) => EditorView.decorations.from(field)
676
+ });
540
677
  function injectStyles(doc) {
541
678
  const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
542
679
  if (existing) {
@@ -680,11 +817,11 @@ function navNodeOrder(a, b) {
680
817
  function applyRawHtmlImageSizeAttributes(image) {
681
818
  const width = image.getAttribute('width');
682
819
  if (width && /^\d+$/.test(width) && !image.style.width) {
683
- image.style.width = `${width}px`;
820
+ image.style.width = `calc(${width}px * var(--mdz-zoom, 1))`;
684
821
  }
685
822
  const height = image.getAttribute('height');
686
823
  if (height && /^\d+$/.test(height) && !image.style.height) {
687
- image.style.height = `${height}px`;
824
+ image.style.height = `calc(${height}px * var(--mdz-zoom, 1))`;
688
825
  }
689
826
  }
690
827
  // Maps the legacy raw HTML `align="left"|"right"` attribute (or the editor's
@@ -720,6 +857,53 @@ export class MdzipWorkspaceView {
720
857
  // trigger) there's no "currently open document" to race against and no
721
858
  // de-dupe/staleness guard is needed.
722
859
  this.packFilesDialogState = null;
860
+ // Tracks an in-progress progressive (chunked) preview render so Copy All
861
+ // can force-drain whatever's left unmounted. `cursor` is how many of
862
+ // `chunks` are mounted so far; null once everything's mounted (or when
863
+ // progressive rendering isn't active at all) — that's Copy All's signal
864
+ // to skip the dialog and copy instantly. `sentinelHandle` is the
865
+ // scroll-driven continuation's IntersectionObserver, if one is currently
866
+ // armed; Copy All tears it down before manually draining so the two don't
867
+ // race and double-mount the same chunk.
868
+ this.chunkedRenderState = null;
869
+ // Per-generation memo of renderChunk's raw HTML output, keyed by chunk
870
+ // index, shared between the DOM-mount path (renderAndMountChunkBatch) and
871
+ // Copy All with Images (renderFullDocumentHtml) so whichever renders a
872
+ // given chunk first is the only one that pays for it. A chunk's HTML is
873
+ // written at most once per generation, never recomputed-and-compared: the
874
+ // shipped mermaid extension mints each diagram's SVG id from a counter
875
+ // that lives on the extension instance for the view's whole lifetime, so
876
+ // re-rendering the same chunk twice yields different-but-valid output —
877
+ // only a genuine write-once cache is safe to share across call sites.
878
+ // Deliberately a separate field from chunkedRenderState (not nested in
879
+ // it): that becomes null as soon as every chunk is mounted, but this
880
+ // needs to stay alive for the whole generation, including after mounting
881
+ // finishes — an idle, fully-mounted document is exactly when Copy All
882
+ // should get full reuse.
883
+ this.chunkHtmlCache = null;
884
+ // Promise-based like packFilesDialogState, but there's no caller awaiting
885
+ // a decision here — `abort` is Copy All's own cancellation switch, wired
886
+ // to the dialog's Cancel button. `label` distinguishes Copy All's single
887
+ // "Rendering the full document" phase from Copy All with Images' two
888
+ // phases ("Rendering the document" then "Embedding images").
889
+ this.copyRenderDialogState = null;
890
+ // Third state sharing the same dialog element as copyRenderDialogState
891
+ // (all three are mutually exclusive) — set once rendering/embedding
892
+ // finishes for a copy that showed the progress dialog. The clipboard
893
+ // write itself is deliberately *not* fired automatically: the async
894
+ // Clipboard API requires a recent user gesture, and by the time a
895
+ // multi-second (sometimes multi-minute) prepare phase finishes, the
896
+ // gesture that triggered Copy All has expired — Chrome rejects the write
897
+ // with "blocked due to lack of user activation". Waiting for the user to
898
+ // click the dialog's own Copy button gives the write a fresh gesture to
899
+ // run inside. `perform` does the actual write when that happens.
900
+ this.copyReadyState = null;
901
+ // Set once a copy that showed the progress dialog finishes (a rejection
902
+ // included — see `copyReadyState` above for why that write happens from
903
+ // a button click, not automatically), so the completion message stays up
904
+ // until the user dismisses it, rather than a fleeting toast easy to miss
905
+ // after a long wait.
906
+ this.copyRenderDoneState = null;
723
907
  this.navPaneWidth = 280;
724
908
  this.splitRatio = 0.5;
725
909
  this.resizing = false;
@@ -745,6 +929,12 @@ export class MdzipWorkspaceView {
745
929
  this.tooltipState = null;
746
930
  this.tooltipShowTimer = null;
747
931
  this.tooltipHideTimer = null;
932
+ this.copyToastHideTimer = null;
933
+ // Not readonly: tests override these to keep the "clipboard write hangs
934
+ // forever" case fast rather than actually waiting out the real production
935
+ // timeouts.
936
+ this.clipboardWriteTimeoutMs = 30000;
937
+ this.clipboardFallbackWriteTimeoutMs = 15000;
748
938
  this.cmEditor = null;
749
939
  this.readOnlyCompartment = new Compartment();
750
940
  this.lineNumbersCompartment = new Compartment();
@@ -756,6 +946,16 @@ export class MdzipWorkspaceView {
756
946
  // syncScrollFromPreview/syncScrollToPreview).
757
947
  this.lastSyncedEditorScrollTop = null;
758
948
  this.lastSyncedPreviewScrollTop = null;
949
+ // Guards syncScrollToPreviewBottom against overlapping drains: set to the
950
+ // chunkedRenderState generation currently being force-drained, null when
951
+ // none is in flight. A second bottom-edge sync that arrives mid-drain
952
+ // (e.g. repeated wheel events once the editor is already pinned at max
953
+ // scrollTop) just no-ops — the in-flight call already owns finishing the
954
+ // job for that generation.
955
+ this.bottomDrainGeneration = null;
956
+ // Non-null while the scroll-to-bottom catch-up toast is showing (past its
957
+ // debounce) — see syncScrollToPreviewBottom.
958
+ this.scrollCatchUpState = null;
759
959
  this.markdownExtensions = [];
760
960
  this.entryRenderers = [];
761
961
  this.renderingService = new MdzipRenderingService();
@@ -779,8 +979,10 @@ export class MdzipWorkspaceView {
779
979
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
780
980
  this.navigationMode = options.navigationMode ?? 'editor';
781
981
  this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
982
+ this.progressiveTextRendering = options.progressiveTextRendering ?? false;
782
983
  this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
783
984
  this.contentDensity = options.contentDensity ?? 'comfortable';
985
+ this.previewMaxWidth = options.previewMaxWidth;
784
986
  this.layout = options.initialLayout ?? defaultLayoutForPolicy(this.controlPolicy);
785
987
  this.colorScheme = options.initialColorScheme
786
988
  ?? (container.ownerDocument.defaultView?.matchMedia('(prefers-color-scheme: dark)').matches
@@ -853,6 +1055,17 @@ export class MdzipWorkspaceView {
853
1055
  this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
854
1056
  this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
855
1057
  this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
1058
+ this.elCopyRenderDialog = q('[data-ref="copy-render-dialog"]');
1059
+ this.elCopyRenderHeading = q('[data-ref="copy-render-heading"]');
1060
+ this.elCopyRenderProgressSection = q('[data-ref="copy-render-progress-section"]');
1061
+ this.elCopyRenderProgressBar = q('[data-ref="copy-render-progress-bar"]');
1062
+ this.elCopyRenderProgressText = q('[data-ref="copy-render-progress-text"]');
1063
+ this.elCopyRenderReadyText = q('[data-ref="copy-render-ready-text"]');
1064
+ this.elCopyRenderDoneText = q('[data-ref="copy-render-done-text"]');
1065
+ this.elCopyRenderCancelBtn = q('[data-ref="copy-render-cancel-btn"]');
1066
+ this.elCopyRenderReadyCancelBtn = q('[data-ref="copy-render-ready-cancel-btn"]');
1067
+ this.elCopyRenderConfirmBtn = q('[data-ref="copy-render-confirm-btn"]');
1068
+ this.elCopyRenderDismissBtn = q('[data-ref="copy-render-dismiss-btn"]');
856
1069
  this.elNavMenu = q('[data-ref="nav-menu"]');
857
1070
  this.elNameDialog = q('[data-ref="name-dialog"]');
858
1071
  this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
@@ -864,6 +1077,7 @@ export class MdzipWorkspaceView {
864
1077
  this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
865
1078
  this.elReplaceInput = q('[data-ref="replace-input"]');
866
1079
  this.elTooltip = q('[data-ref="tooltip"]');
1080
+ this.elCopyToast = q('[data-ref="copy-toast"]');
867
1081
  this.elEmptyState = q('[data-ref="empty-state"]');
868
1082
  this.prepareTooltips();
869
1083
  this.attachEvents();
@@ -885,6 +1099,7 @@ export class MdzipWorkspaceView {
885
1099
  this.resetRenderingState();
886
1100
  this.cmEditor?.destroy();
887
1101
  this.cmEditor = null;
1102
+ this.workspace?.dispose();
888
1103
  this.workspace = null;
889
1104
  this.replaceAssetSession(null);
890
1105
  this.conversionDocumentGeneration += 1;
@@ -966,6 +1181,7 @@ export class MdzipWorkspaceView {
966
1181
  this.resetRenderingState();
967
1182
  this.cmEditor?.destroy();
968
1183
  this.cmEditor = null;
1184
+ this.workspace?.dispose();
969
1185
  this.workspace = null;
970
1186
  this.replaceAssetSession(null);
971
1187
  this.conversionDocumentGeneration += 1;
@@ -1159,6 +1375,12 @@ export class MdzipWorkspaceView {
1159
1375
  this.teardownEntryRenderer();
1160
1376
  // Release any whenRendered() waiters so their promises do not hang.
1161
1377
  this.flushRenderedWaiters();
1378
+ try {
1379
+ this.workspace?.dispose();
1380
+ }
1381
+ catch {
1382
+ // Ignore worker teardown errors
1383
+ }
1162
1384
  try {
1163
1385
  this.cmEditor?.destroy();
1164
1386
  }
@@ -1254,10 +1476,26 @@ export class MdzipWorkspaceView {
1254
1476
  this.contentDensity = nextContentDensity;
1255
1477
  this.applyDensityClasses();
1256
1478
  }
1479
+ /**
1480
+ * Sets the preview reading-column width. Developer-facing only — there is
1481
+ * no toolbar UI for this. Pass `undefined` to return to the built-in
1482
+ * default (which scales with zoom); any other value is used exactly as
1483
+ * given and does not scale with zoom (see {@link MdzipPreviewMaxWidth}).
1484
+ */
1485
+ setPreviewMaxWidth(value) {
1486
+ if (value === this.previewMaxWidth) {
1487
+ return;
1488
+ }
1489
+ this.previewMaxWidth = value;
1490
+ this.render();
1491
+ }
1257
1492
  setImageInsertOptions(options) {
1258
1493
  this.options.imageInsertMode = options.imageInsertMode;
1259
1494
  this.options.imageInsertHandler = options.imageInsertHandler;
1260
1495
  }
1496
+ setImageEditOptions(options) {
1497
+ this.options.imageEditHandler = options.imageEditHandler;
1498
+ }
1261
1499
  applyDensityClasses() {
1262
1500
  this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
1263
1501
  this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
@@ -1296,6 +1534,26 @@ export class MdzipWorkspaceView {
1296
1534
  this.options.onFailed?.(error);
1297
1535
  }
1298
1536
  }
1537
+ // A new render generation invalidates any in-progress chunk draining —
1538
+ // Copy All's own abort check unwinds it and hides the dialog. It also
1539
+ // invalidates a "ready to copy" or "done" dialog left over from a
1540
+ // previous document: `copyReadyState.perform` closes over that
1541
+ // document's already-built HTML/text, and clicking Copy against a
1542
+ // now-superseded document would silently copy the wrong content.
1543
+ this.chunkedRenderState = null;
1544
+ this.chunkHtmlCache = null;
1545
+ this.copyRenderDialogState?.abort.abort();
1546
+ this.copyReadyState = null;
1547
+ this.copyRenderDoneState = null;
1548
+ }
1549
+ /** 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. */
1550
+ previewMemoMatchesSnapshot(snapshot) {
1551
+ const memo = this.previewMemo;
1552
+ return !!memo
1553
+ && memo.path === snapshot.currentPath
1554
+ && memo.pathType === snapshot.currentPathType
1555
+ && memo.text === snapshot.currentText
1556
+ && memo.colorScheme === this.colorScheme;
1299
1557
  }
1300
1558
  updatePreview(snapshot, entryClaimed) {
1301
1559
  if (entryClaimed) {
@@ -1311,11 +1569,7 @@ export class MdzipWorkspaceView {
1311
1569
  return;
1312
1570
  }
1313
1571
  const memo = this.previewMemo;
1314
- if (memo
1315
- && memo.path === snapshot.currentPath
1316
- && memo.pathType === snapshot.currentPathType
1317
- && memo.text === snapshot.currentText
1318
- && memo.colorScheme === this.colorScheme) {
1572
+ if (this.previewMemoMatchesSnapshot(snapshot)) {
1319
1573
  // Nothing that feeds the preview changed; keep the existing DOM and any
1320
1574
  // mounted extension handles.
1321
1575
  return;
@@ -1356,6 +1610,10 @@ export class MdzipWorkspaceView {
1356
1610
  const abort = new AbortController();
1357
1611
  this.previewAbort = abort;
1358
1612
  const context = this.createMarkdownContext(snapshot, abort.signal);
1613
+ if (this.progressiveTextRendering && this.renderingService.supportsChunking) {
1614
+ this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1615
+ return;
1616
+ }
1359
1617
  let result;
1360
1618
  try {
1361
1619
  result = this.renderingService.renderMarkdown(snapshot.currentText, context);
@@ -1427,28 +1685,86 @@ export class MdzipWorkspaceView {
1427
1685
  */
1428
1686
  mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
1429
1687
  this.elPreviewContent.innerHTML = html;
1430
- const session = this.assetSession;
1431
- const document = this.elPreviewContent.ownerDocument;
1688
+ // Cheap pass over every <img> happens before extensions/code-block
1689
+ // controls mount, same relative order as always — see
1690
+ // collectPendingImages for why it's split from the (expensive) slot
1691
+ // creation that follows.
1692
+ const pending = this.collectPendingImages([this.elPreviewContent], animateImageHydration);
1693
+ this.mountPreviewExtensions(context, generation);
1694
+ const codeBlockHandle = this.mountCodeBlockControls();
1695
+ if (codeBlockHandle) {
1696
+ this.previewHandles.push(codeBlockHandle);
1697
+ }
1698
+ this.firePreviewRendered(snapshot, generation);
1699
+ this.hydrateImages(pending, context, generation, animateImageHydration, () => {
1700
+ this.fireAssetsHydrated(snapshot, generation);
1701
+ });
1702
+ }
1703
+ /**
1704
+ * Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
1705
+ * mutation. Archive-relative sources have their `src` stripped here
1706
+ * (immediately, so the browser never fires a bad network request for the
1707
+ * archive-relative path) and are returned for {@link hydrateImages};
1708
+ * external/data/fragment sources are left untouched (just get their align
1709
+ * class, if any — they never get a slot). Returns `[]` without touching
1710
+ * anything when there's no asset session to resolve archive images
1711
+ * against, matching `mountPreviewHtml`'s no-op image handling.
1712
+ */
1713
+ collectPendingImages(roots, animateImageHydration) {
1714
+ if (!this.assetSession) {
1715
+ return [];
1716
+ }
1432
1717
  const pending = [];
1433
- for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
1434
- applyRawHtmlImageSizeAttributes(image);
1435
- const source = image.getAttribute('src');
1436
- // Leave external, protocol-relative, data, and fragment URLs untouched.
1437
- if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1438
- const alignClass = rawHtmlImageAlignClass(image);
1439
- if (alignClass) {
1440
- image.classList.add(alignClass);
1718
+ for (const root of roots) {
1719
+ for (const image of Array.from(root.querySelectorAll('img'))) {
1720
+ applyRawHtmlImageSizeAttributes(image);
1721
+ const source = image.getAttribute('src');
1722
+ // Leave external, protocol-relative, data, and fragment URLs untouched.
1723
+ if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1724
+ const alignClass = rawHtmlImageAlignClass(image);
1725
+ if (alignClass) {
1726
+ image.classList.add(alignClass);
1727
+ }
1728
+ continue;
1441
1729
  }
1442
- continue;
1730
+ image.removeAttribute('src');
1731
+ if (animateImageHydration) {
1732
+ image.classList.add('mdzip-image-loading');
1733
+ }
1734
+ pending.push({ image, source });
1443
1735
  }
1444
- // Drop the archive-relative src so the browser does not fetch the bad
1445
- // path. Wrap the image in a collapsed slot so the text stays compact and
1446
- // immediately readable; the slot eases open to the reserved height once
1447
- // the image resolves.
1448
- image.removeAttribute('src');
1449
- if (animateImageHydration) {
1450
- image.classList.add('mdzip-image-loading');
1736
+ }
1737
+ return pending;
1738
+ }
1739
+ /**
1740
+ * Slot-wraps and resolves `pending` images (from {@link collectPendingImages}),
1741
+ * only decompressing/resolving ones near the viewport up front — a
1742
+ * document with a few hundred distinct embedded images would otherwise
1743
+ * decompress and blob-URL every one of them synchronously, freezing the UI
1744
+ * for as long as that takes. Falls back to eager resolution when
1745
+ * IntersectionObserver isn't available (e.g. non-browser hosts). Calls
1746
+ * `onSettled` once every image in `pending` has resolved (or immediately
1747
+ * if `pending` is empty) — callers that want a single "hydrated" signal
1748
+ * for a whole preview pass `pending` from every root at once; callers
1749
+ * mounting one chunk among several within the same generation (see
1750
+ * {@link mountChunkBatch}) pass a no-op instead, so the signal only fires
1751
+ * once, for the batch it's semantically tied to.
1752
+ */
1753
+ hydrateImages(pending, context, generation, animateImageHydration, onSettled) {
1754
+ const session = this.assetSession;
1755
+ if (!session || pending.length === 0) {
1756
+ onSettled();
1757
+ return;
1758
+ }
1759
+ const document = this.elPreviewContent.ownerDocument;
1760
+ let remaining = pending.length;
1761
+ const settle = () => {
1762
+ remaining -= 1;
1763
+ if (remaining === 0) {
1764
+ onSettled();
1451
1765
  }
1766
+ };
1767
+ const createImageSlot = (image) => {
1452
1768
  const slot = document.createElement('span');
1453
1769
  slot.className = animateImageHydration
1454
1770
  ? 'mdzip-image-slot'
@@ -1462,26 +1778,9 @@ export class MdzipWorkspaceView {
1462
1778
  }
1463
1779
  image.parentNode?.insertBefore(slot, image);
1464
1780
  slot.appendChild(image);
1465
- pending.push({ image, slot, source });
1466
- }
1467
- this.mountPreviewExtensions(context, generation);
1468
- const codeBlockHandle = this.mountCodeBlockControls();
1469
- if (codeBlockHandle) {
1470
- this.previewHandles.push(codeBlockHandle);
1471
- }
1472
- this.firePreviewRendered(snapshot, generation);
1473
- if (!session || pending.length === 0) {
1474
- this.fireAssetsHydrated(snapshot, generation);
1475
- return;
1476
- }
1477
- let remaining = pending.length;
1478
- const settle = () => {
1479
- remaining -= 1;
1480
- if (remaining === 0) {
1481
- this.fireAssetsHydrated(snapshot, generation);
1482
- }
1781
+ return slot;
1483
1782
  };
1484
- for (const { image, slot, source } of pending) {
1783
+ const hydrate = (image, slot, source) => {
1485
1784
  void session.resolveImage(source, context.currentPath).then((resolved) => {
1486
1785
  if (generation !== this.previewGeneration || context.signal.aborted) {
1487
1786
  settle();
@@ -1513,6 +1812,294 @@ export class MdzipWorkspaceView {
1513
1812
  this.openImageSlot(slot);
1514
1813
  settle();
1515
1814
  });
1815
+ };
1816
+ const observerWindow = document.defaultView;
1817
+ if (!observerWindow?.IntersectionObserver) {
1818
+ for (const { image, source } of pending) {
1819
+ hydrate(image, createImageSlot(image), source);
1820
+ }
1821
+ return;
1822
+ }
1823
+ const bySlot = new Map();
1824
+ const observer = new observerWindow.IntersectionObserver((entries) => {
1825
+ for (const entry of entries) {
1826
+ if (!entry.isIntersecting)
1827
+ continue;
1828
+ observer.unobserve(entry.target);
1829
+ const item = bySlot.get(entry.target);
1830
+ if (item)
1831
+ hydrate(item.image, entry.target, item.source);
1832
+ }
1833
+ }, { root: this.elPreviewPane, rootMargin: '600px 0px' });
1834
+ this.previewHandles.push({ destroy: () => observer.disconnect() });
1835
+ // Slot creation + observer registration is a real DOM tree mutation per
1836
+ // image (as opposed to the cheap attribute-only pass in
1837
+ // collectPendingImages), and at thousands of occurrences (e.g. a chat
1838
+ // export where a handful of distinct avatars repeat across every row)
1839
+ // doing it all synchronously blocks the main thread for seconds.
1840
+ // Time-budget it across animation frames instead — the first chunk
1841
+ // still runs synchronously as part of this call (no yield before it),
1842
+ // so small documents (every existing test) see their slots exist
1843
+ // immediately, same as before.
1844
+ const CHUNK_BUDGET_MS = 8;
1845
+ let cursor = 0;
1846
+ const processChunk = () => {
1847
+ if (generation !== this.previewGeneration || context.signal.aborted)
1848
+ return;
1849
+ const chunkStart = observerWindow.performance.now();
1850
+ while (cursor < pending.length && observerWindow.performance.now() - chunkStart < CHUNK_BUDGET_MS) {
1851
+ const item = pending[cursor];
1852
+ cursor += 1;
1853
+ const slot = createImageSlot(item.image);
1854
+ bySlot.set(slot, item);
1855
+ observer.observe(slot);
1856
+ }
1857
+ if (cursor < pending.length) {
1858
+ observerWindow.requestAnimationFrame(processChunk);
1859
+ }
1860
+ };
1861
+ processChunk();
1862
+ }
1863
+ /**
1864
+ * Opt-in (`progressiveTextRendering`) alternative to the whole-document
1865
+ * `renderMarkdown()` path above: tokenizes the markdown once, then renders
1866
+ * and mounts it in chunks near the viewport instead of all at once — see
1867
+ * {@link mountChunkedPreview}. Only reachable when
1868
+ * `renderingService.supportsChunking` is true (the default marked-based
1869
+ * renderer); the caller already checked that.
1870
+ */
1871
+ renderChunkedPreview(snapshot, context, generation, animateImageHydration) {
1872
+ let tokens;
1873
+ try {
1874
+ tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
1875
+ }
1876
+ catch (error) {
1877
+ this.options.onFailed?.(error);
1878
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
1879
+ this.firePreviewRendered(snapshot, generation);
1880
+ this.fireAssetsHydrated(snapshot, generation);
1881
+ return;
1882
+ }
1883
+ if (Array.isArray(tokens)) {
1884
+ this.mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration);
1885
+ return;
1886
+ }
1887
+ void tokens.then((resolved) => {
1888
+ if (generation !== this.previewGeneration || context.signal.aborted)
1889
+ return;
1890
+ this.mountChunkedPreview(resolved, snapshot, context, generation, animateImageHydration);
1891
+ }).catch((error) => {
1892
+ if (generation !== this.previewGeneration || context.signal.aborted)
1893
+ return;
1894
+ if (error?.name !== 'AbortError') {
1895
+ this.options.onFailed?.(error);
1896
+ }
1897
+ });
1898
+ }
1899
+ /**
1900
+ * Groups tokens into chunks and mounts them: an initial batch synchronously
1901
+ * (enough for a small document to behave exactly like the non-chunked
1902
+ * path), then the rest as the user scrolls near a trailing sentinel
1903
+ * element, via the same `IntersectionObserver` + time-budgeted-rAF pattern
1904
+ * already used for image hydration — just for "need more text" instead of
1905
+ * "need this image". `onAssetsHydrated` fires once for the initial batch's
1906
+ * images only (not re-fired per later chunk) — `onPreviewRendered` still
1907
+ * means "the initial batch is in the DOM", same intent as the non-chunked
1908
+ * path, just proportional to the viewport instead of the whole document.
1909
+ */
1910
+ mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration) {
1911
+ this.elPreviewContent.replaceChildren();
1912
+ // Also called with no options in renderFullDocumentHtml — see the
1913
+ // comment there; keep both call sites' chunking in sync.
1914
+ const chunks = groupTokensIntoChunks(tokens);
1915
+ if (chunks.length === 0) {
1916
+ this.chunkedRenderState = null;
1917
+ this.firePreviewRendered(snapshot, generation);
1918
+ this.fireAssetsHydrated(snapshot, generation);
1919
+ return;
1920
+ }
1921
+ this.chunkedRenderState = { chunks, cursor: 0, context, generation, animateImageHydration, sentinelHandle: null };
1922
+ void this.mountChunkBatch(chunks, 0, context, generation, animateImageHydration, () => {
1923
+ this.fireAssetsHydrated(snapshot, generation);
1924
+ }).then((cursor) => {
1925
+ if (generation !== this.previewGeneration || context.signal.aborted)
1926
+ return;
1927
+ this.recordChunkProgress(generation, chunks, cursor);
1928
+ this.firePreviewRendered(snapshot, generation);
1929
+ if (cursor < chunks.length) {
1930
+ this.armChunkSentinel(chunks, cursor, context, generation, animateImageHydration);
1931
+ }
1932
+ });
1933
+ }
1934
+ /**
1935
+ * Updates `chunkedRenderState.cursor` after a batch mounts, or clears the
1936
+ * whole state once every chunk is in the DOM — that `null` is Copy All's
1937
+ * signal that there's nothing left to force-render. A no-op if a newer
1938
+ * render generation has already superseded this one.
1939
+ */
1940
+ recordChunkProgress(generation, chunks, cursor) {
1941
+ if (this.chunkedRenderState?.generation !== generation)
1942
+ return;
1943
+ if (cursor >= chunks.length) {
1944
+ this.chunkedRenderState = null;
1945
+ }
1946
+ else {
1947
+ this.chunkedRenderState.cursor = cursor;
1948
+ }
1949
+ }
1950
+ /**
1951
+ * Renders+appends chunks starting at `startCursor` up to a char budget
1952
+ * (enough for one screenful, roughly) *or* a wall-clock time budget,
1953
+ * whichever comes first — char count alone is a poor proxy for cost on a
1954
+ * document where some chunks are plain text and others are dense with
1955
+ * `<img>` tags (parsing + sanitizing + inserting into an already-huge
1956
+ * `elPreviewContent` gets measurably more expensive per chunk as an
1957
+ * image-heavy document's accumulated DOM grows); the time budget catches
1958
+ * what the char budget alone misses. Mounts extensions/code-block controls
1959
+ * for exactly the chunks it appended (never re-scanning earlier ones), and
1960
+ * returns the cursor to resume from plus those chunks' roots (for the
1961
+ * caller to run `collectPendingImages` on). Shared by every caller that
1962
+ * mounts chunk batches — the initial batch, every later sentinel-triggered
1963
+ * continuation, and Copy All's drain — image hydration itself is *not*
1964
+ * included here; see {@link mountChunkBatch} and
1965
+ * {@link drainRemainingChunks} for the two different ways callers pace it.
1966
+ */
1967
+ async renderAndMountChunkBatch(chunks, startCursor, context, generation) {
1968
+ const BATCH_CHAR_BUDGET = 4000;
1969
+ const BATCH_TIME_BUDGET_MS = 10;
1970
+ const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
1971
+ const batchStart = clock.now();
1972
+ let cursor = startCursor;
1973
+ let renderedChars = 0;
1974
+ const mountedRoots = [];
1975
+ while (cursor < chunks.length
1976
+ && renderedChars < BATCH_CHAR_BUDGET
1977
+ && (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
1978
+ if (generation !== this.previewGeneration || context.signal.aborted)
1979
+ return { cursor, mountedRoots };
1980
+ const chunkTokens = chunks[cursor];
1981
+ const chunkIndex = cursor;
1982
+ cursor += 1;
1983
+ let html;
1984
+ let cacheHit;
1985
+ try {
1986
+ cacheHit = this.chunkHtmlCache?.generation === generation
1987
+ ? this.chunkHtmlCache.html.get(chunkIndex)
1988
+ : undefined;
1989
+ if (cacheHit !== undefined) {
1990
+ html = cacheHit;
1991
+ }
1992
+ else {
1993
+ const result = this.renderingService.renderChunk(chunkTokens, context);
1994
+ html = typeof result === 'string' ? result : await result;
1995
+ }
1996
+ }
1997
+ catch (error) {
1998
+ if (error?.name !== 'AbortError') {
1999
+ this.options.onFailed?.(error);
2000
+ }
2001
+ continue;
2002
+ }
2003
+ if (generation !== this.previewGeneration || context.signal.aborted)
2004
+ return { cursor, mountedRoots };
2005
+ if (cacheHit === undefined) {
2006
+ // groupTokensIntoChunks is also called with no options in
2007
+ // renderFullDocumentHtml — keep both call sites' chunking in sync,
2008
+ // since chunk index is this cache's only key.
2009
+ if (this.chunkHtmlCache?.generation !== generation) {
2010
+ this.chunkHtmlCache = { generation, html: new Map() };
2011
+ }
2012
+ this.chunkHtmlCache.html.set(chunkIndex, html);
2013
+ }
2014
+ const root = this.appendChunkHtml(html);
2015
+ mountedRoots.push(root);
2016
+ renderedChars += html.length;
2017
+ }
2018
+ for (const root of mountedRoots) {
2019
+ this.mountPreviewExtensions(context, generation, root);
2020
+ const codeBlockHandle = this.mountCodeBlockControls(root);
2021
+ if (codeBlockHandle) {
2022
+ this.previewHandles.push(codeBlockHandle);
2023
+ }
2024
+ }
2025
+ return { cursor, mountedRoots };
2026
+ }
2027
+ /**
2028
+ * `renderAndMountChunkBatch` plus its own immediate, independent
2029
+ * `hydrateImages` pass — the shape every caller except Copy All's drain
2030
+ * wants: mount a batch, then hydrate whatever images it contained,
2031
+ * decoupled from mounting the next batch. Scroll-paced callers (the
2032
+ * initial batch, every sentinel continuation) are naturally rate-limited
2033
+ * by how fast the user scrolls, so one independent hydration loop per
2034
+ * batch never has a chance to pile up against another.
2035
+ */
2036
+ async mountChunkBatch(chunks, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2037
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, startCursor, context, generation);
2038
+ // Same relative order as the non-chunked path: cheap image pass, then
2039
+ // the (expensive) slot/observe pass.
2040
+ const pending = this.collectPendingImages(mountedRoots, animateImageHydration);
2041
+ this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
2042
+ return cursor;
2043
+ }
2044
+ /** Wraps one chunk's rendered HTML in a mount boundary and appends it. See the `.mdzip-chunk` CSS rules for why. */
2045
+ appendChunkHtml(html) {
2046
+ const doc = this.elPreviewContent.ownerDocument;
2047
+ const wrapper = doc.createElement('div');
2048
+ wrapper.className = 'mdzip-chunk';
2049
+ wrapper.innerHTML = html;
2050
+ this.elPreviewContent.appendChild(wrapper);
2051
+ return wrapper;
2052
+ }
2053
+ /**
2054
+ * Appends a trailing sentinel and mounts the next chunk batch once it's
2055
+ * within `rootMargin` of the viewport — a larger margin than images'
2056
+ * (`hydrateImages`' 600px) since keeping text ahead of scroll is cheap
2057
+ * relative to keeping images ahead. Re-arms itself after each batch until
2058
+ * every chunk is mounted. Falls back to mounting everything immediately
2059
+ * when IntersectionObserver isn't available, matching `hydrateImages`.
2060
+ */
2061
+ armChunkSentinel(chunks, cursor, context, generation, animateImageHydration) {
2062
+ const doc = this.elPreviewContent.ownerDocument;
2063
+ const observerWindow = doc.defaultView;
2064
+ const mountNext = (nextCursor) => {
2065
+ void this.mountChunkBatch(chunks, nextCursor, context, generation, animateImageHydration, () => { })
2066
+ .then((newCursor) => {
2067
+ if (generation !== this.previewGeneration || context.signal.aborted)
2068
+ return;
2069
+ this.recordChunkProgress(generation, chunks, newCursor);
2070
+ if (newCursor < chunks.length) {
2071
+ this.armChunkSentinel(chunks, newCursor, context, generation, animateImageHydration);
2072
+ }
2073
+ });
2074
+ };
2075
+ if (!observerWindow?.IntersectionObserver) {
2076
+ mountNext(cursor);
2077
+ return;
2078
+ }
2079
+ const sentinel = doc.createElement('div');
2080
+ sentinel.className = 'mdzip-chunk-sentinel';
2081
+ this.elPreviewContent.appendChild(sentinel);
2082
+ const observer = new observerWindow.IntersectionObserver((entries) => {
2083
+ for (const entry of entries) {
2084
+ if (!entry.isIntersecting)
2085
+ continue;
2086
+ observer.disconnect();
2087
+ sentinel.remove();
2088
+ if (this.chunkedRenderState?.generation === generation) {
2089
+ this.chunkedRenderState.sentinelHandle = null;
2090
+ }
2091
+ mountNext(cursor);
2092
+ }
2093
+ }, { root: this.elPreviewPane, rootMargin: '1600px 0px' });
2094
+ observer.observe(sentinel);
2095
+ // Stored on both previewHandles (torn down on the next render generation,
2096
+ // like every other preview handle) and chunkedRenderState.sentinelHandle
2097
+ // (torn down early by Copy All so its own manual drain doesn't race this
2098
+ // observer and double-mount a chunk).
2099
+ const handle = { destroy: () => { observer.disconnect(); sentinel.remove(); } };
2100
+ this.previewHandles.push(handle);
2101
+ if (this.chunkedRenderState?.generation === generation) {
2102
+ this.chunkedRenderState.sentinelHandle = handle;
1516
2103
  }
1517
2104
  }
1518
2105
  /**
@@ -1575,13 +2162,20 @@ export class MdzipWorkspaceView {
1575
2162
  + 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
1576
2163
  + 'blob: and data:.'));
1577
2164
  }
1578
- mountPreviewExtensions(context, generation) {
2165
+ /**
2166
+ * `root` scopes extension `mount()` calls to a specific chunk instead of
2167
+ * the whole preview — used by chunked rendering (see
2168
+ * {@link mountChunkedPreview}), where extensions run once per newly
2169
+ * appended chunk rather than once over the whole document. Defaults to
2170
+ * the whole preview content, matching the non-chunked path exactly.
2171
+ */
2172
+ mountPreviewExtensions(context, generation, root = this.elPreviewContent) {
1579
2173
  for (const extension of this.markdownExtensions) {
1580
2174
  if (!extension.mount) {
1581
2175
  continue;
1582
2176
  }
1583
2177
  try {
1584
- const mounted = extension.mount(this.elPreviewContent, context);
2178
+ const mounted = extension.mount(root, context);
1585
2179
  if (isThenable(mounted)) {
1586
2180
  void Promise.resolve(mounted).then((handle) => {
1587
2181
  if (!handle) {
@@ -1621,12 +2215,15 @@ export class MdzipWorkspaceView {
1621
2215
  * extension) — every consumer gets this automatically, gated by policy
1622
2216
  * rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
1623
2217
  * extension-provided `pre > code` blocks already exist in the DOM. See #29.
2218
+ *
2219
+ * `root` scopes the scan to a specific chunk instead of the whole preview
2220
+ * — see {@link mountPreviewExtensions} for why.
1624
2221
  */
1625
- mountCodeBlockControls() {
2222
+ mountCodeBlockControls(root = this.elPreviewContent) {
1626
2223
  if (!this.controlPolicy.codeBlockTools) {
1627
2224
  return null;
1628
2225
  }
1629
- const codeEls = Array.from(this.elPreviewContent.querySelectorAll('pre > code'));
2226
+ const codeEls = Array.from(root.querySelectorAll('pre > code'));
1630
2227
  if (codeEls.length === 0) {
1631
2228
  return null;
1632
2229
  }
@@ -1969,6 +2566,8 @@ export class MdzipWorkspaceView {
1969
2566
  syntaxHighlighting(mdzipMarkdownHighlight),
1970
2567
  hardBreakMarkerHighlight,
1971
2568
  htmlTagMarkerHighlight,
2569
+ noSpellcheckHighlight,
2570
+ imageEditAffordanceField,
1972
2571
  EditorView.lineWrapping,
1973
2572
  dropCursor(),
1974
2573
  // Content is contenteditable, but browsers don't agree on a default
@@ -2024,6 +2623,23 @@ export class MdzipWorkspaceView {
2024
2623
  void self.handleEditorImageDrop(file, event.clientX, event.clientY);
2025
2624
  return true;
2026
2625
  }
2626
+ },
2627
+ click(event, view) {
2628
+ // Fully inert unless a host has opted in — no parse cost, no
2629
+ // widget, matching #39's "default editor behavior unchanged".
2630
+ if (!self.options.imageEditHandler) {
2631
+ return;
2632
+ }
2633
+ const target = event.target;
2634
+ if (target.closest('[data-mdzip-image-edit-affordance]')) {
2635
+ return;
2636
+ }
2637
+ const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
2638
+ const hit = pos === null ? null : findImageReferenceAtOffset(view.state, pos);
2639
+ view.dispatch({ effects: imageEditAffordanceEffect.of(hit ? { from: hit.from, to: hit.to } : null) });
2640
+ },
2641
+ blur(_event, view) {
2642
+ view.dispatch({ effects: imageEditAffordanceEffect.of(null) });
2027
2643
  }
2028
2644
  }),
2029
2645
  ],
@@ -2034,6 +2650,10 @@ export class MdzipWorkspaceView {
2034
2650
  if (scroller) {
2035
2651
  scroller.addEventListener('scroll', () => self.syncScrollToPreview());
2036
2652
  }
2653
+ editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
2654
+ const { from, to } = event.detail;
2655
+ void self.openImageEditFlow(from, to);
2656
+ });
2037
2657
  return editor;
2038
2658
  }
2039
2659
  async ensureCmEditor(force = false) {
@@ -2049,6 +2669,23 @@ export class MdzipWorkspaceView {
2049
2669
  const snapshot = this.workspace?.snapshot() ?? null;
2050
2670
  this.elEmptyState.hidden = snapshot !== null;
2051
2671
  this.elWorkspaceShell.hidden = snapshot === null;
2672
+ // Must run before the no-snapshot early return below: the pack-files dialog is
2673
+ // deciding how to create a workspace, so it opens (via openPackFilesDialog ->
2674
+ // render()) precisely when no workspace exists yet. Bug found 2026-08-19 —
2675
+ // this used to live after the return, making it dead code for this dialog's
2676
+ // entire lifetime; the dialog's state was set correctly but never reached the
2677
+ // DOM, leaving the empty-state placeholder showing and the caller's promise
2678
+ // hanging forever.
2679
+ this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
2680
+ if (this.packFilesDialogState) {
2681
+ const { request } = this.packFilesDialogState;
2682
+ this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
2683
+ .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
2684
+ .join('');
2685
+ this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
2686
+ this.elPackFilesModeDocument.checked = true;
2687
+ this.elPackFilesModeProject.checked = false;
2688
+ }
2052
2689
  if (!snapshot) {
2053
2690
  this.elDocumentStrip.hidden = true;
2054
2691
  this.elToolbar.hidden = true;
@@ -2092,6 +2729,13 @@ export class MdzipWorkspaceView {
2092
2729
  this.elRoot.style.setProperty('--mdz-zoom', String(this.zoom));
2093
2730
  this.elRoot.style.setProperty('--nav-pane-width', `${this.navPaneWidth}px`);
2094
2731
  this.elRoot.style.setProperty('--split-edit-ratio', String(this.splitRatio));
2732
+ const previewMaxWidthPx = resolvePreviewMaxWidthPx(this.previewMaxWidth);
2733
+ if (previewMaxWidthPx === undefined) {
2734
+ this.elRoot.style.removeProperty('--mdzip-preview-content-max-width');
2735
+ }
2736
+ else {
2737
+ this.elRoot.style.setProperty('--mdzip-preview-content-max-width', `${previewMaxWidthPx}px`);
2738
+ }
2095
2739
  this.elRoot.classList.toggle('resizing', this.resizing);
2096
2740
  this.elRoot.classList.toggle('mdzip-theme-dark', this.colorScheme === 'dark');
2097
2741
  this.elRoot.classList.toggle('mdzip-theme-light', this.colorScheme === 'light');
@@ -2226,15 +2870,26 @@ export class MdzipWorkspaceView {
2226
2870
  this.elImageInsertPositionSelect.value = 'inline';
2227
2871
  this.updateImageInsertOptionControls();
2228
2872
  }
2229
- this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
2230
- if (this.packFilesDialogState) {
2231
- const { request } = this.packFilesDialogState;
2232
- this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
2233
- .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
2234
- .join('');
2235
- this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
2236
- this.elPackFilesModeDocument.checked = true;
2237
- this.elPackFilesModeProject.checked = false;
2873
+ this.elCopyRenderDialog.hidden = this.copyRenderDialogState === null
2874
+ && this.copyReadyState === null
2875
+ && this.copyRenderDoneState === null;
2876
+ this.elCopyRenderProgressSection.hidden = this.copyRenderDialogState === null;
2877
+ this.elCopyRenderReadyText.hidden = this.copyReadyState === null;
2878
+ this.elCopyRenderDoneText.hidden = this.copyRenderDoneState === null;
2879
+ this.elCopyRenderCancelBtn.hidden = this.copyRenderDialogState === null;
2880
+ this.elCopyRenderReadyCancelBtn.hidden = this.copyReadyState === null;
2881
+ this.elCopyRenderConfirmBtn.hidden = this.copyReadyState === null;
2882
+ this.elCopyRenderDismissBtn.hidden = this.copyRenderDoneState === null;
2883
+ if (this.copyRenderDialogState) {
2884
+ this.elCopyRenderHeading.textContent = 'Copying document...';
2885
+ this.updateCopyRenderDialogProgress();
2886
+ }
2887
+ else if (this.copyReadyState) {
2888
+ this.elCopyRenderHeading.textContent = 'Ready to copy';
2889
+ }
2890
+ else if (this.copyRenderDoneState) {
2891
+ this.elCopyRenderHeading.textContent = 'Done';
2892
+ this.elCopyRenderDoneText.textContent = this.copyRenderDoneState.message;
2238
2893
  }
2239
2894
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
2240
2895
  if (this.contextMenuState) {
@@ -2328,10 +2983,11 @@ export class MdzipWorkspaceView {
2328
2983
  }
2329
2984
  });
2330
2985
  // Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
2331
- // both panes' text at once. Scope it to the rendered content instead when
2332
- // focus is inside the preview pane (see the mousedown handler below for
2333
- // how it gets there); the source editor keeps its own defaultKeymap
2334
- // binding since CodeMirror's content root sits outside this element.
2986
+ // both panes' text at once. Scope it to a Copy All of the rendered
2987
+ // content instead when focus is inside the preview pane (see the
2988
+ // mousedown handler below for how it gets there); the source editor
2989
+ // keeps its own defaultKeymap binding since CodeMirror's content root
2990
+ // sits outside this element.
2335
2991
  doc.addEventListener('keydown', (e) => {
2336
2992
  if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
2337
2993
  return;
@@ -2340,7 +2996,7 @@ export class MdzipWorkspaceView {
2340
2996
  return;
2341
2997
  }
2342
2998
  e.preventDefault();
2343
- this.selectAllPreviewContent();
2999
+ void this.copyAllPreviewContent();
2344
3000
  });
2345
3001
  this.elNavBtn.addEventListener('click', () => {
2346
3002
  if (!this.controlPolicy.navigation) {
@@ -2611,6 +3267,28 @@ export class MdzipWorkspaceView {
2611
3267
  this.elPackFilesConfirmBtn.addEventListener('click', () => {
2612
3268
  this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
2613
3269
  });
3270
+ this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-render"]')
3271
+ .addEventListener('click', () => {
3272
+ this.copyRenderDialogState?.abort.abort();
3273
+ });
3274
+ this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-ready"]')
3275
+ .addEventListener('click', () => {
3276
+ this.copyReadyState = null;
3277
+ this.render();
3278
+ });
3279
+ this.elCopyRenderDialog.querySelector('[data-action="confirm-copy-ready"]')
3280
+ .addEventListener('click', () => {
3281
+ // Must call the actual write synchronously from this handler (no
3282
+ // awaits ahead of it) — this click is the fresh user gesture the
3283
+ // Clipboard API needs; losing it to another async hop before the
3284
+ // write starts would defeat the entire reason this button exists.
3285
+ void this.performReadyCopy();
3286
+ });
3287
+ this.elCopyRenderDialog.querySelector('[data-action="dismiss-copy-render"]')
3288
+ .addEventListener('click', () => {
3289
+ this.copyRenderDoneState = null;
3290
+ this.render();
3291
+ });
2614
3292
  this.elNavMenu.addEventListener('click', (e) => {
2615
3293
  e.stopPropagation();
2616
3294
  const item = e.target.closest('[data-menu-action]');
@@ -2876,6 +3554,18 @@ export class MdzipWorkspaceView {
2876
3554
  this.tooltipHideTimer = null;
2877
3555
  }, 40);
2878
3556
  }
3557
+ /** Briefly shows a status message (e.g. "Text copied to clipboard") near the bottom of the view, auto-hiding after a couple of seconds. */
3558
+ showCopyToast(message) {
3559
+ if (this.copyToastHideTimer) {
3560
+ clearTimeout(this.copyToastHideTimer);
3561
+ }
3562
+ this.elCopyToast.textContent = message;
3563
+ this.elCopyToast.hidden = false;
3564
+ this.copyToastHideTimer = setTimeout(() => {
3565
+ this.elCopyToast.hidden = true;
3566
+ this.copyToastHideTimer = null;
3567
+ }, 2000);
3568
+ }
2879
3569
  async save() {
2880
3570
  try {
2881
3571
  const workspace = this.workspace;
@@ -2930,15 +3620,35 @@ export class MdzipWorkspaceView {
2930
3620
  }
2931
3621
  renderMetadata(snapshot) {
2932
3622
  const manifest = snapshot.content.manifest;
3623
+ const isMdz = snapshot.sourceFormat === 'mdz';
2933
3624
  const fields = [
2934
3625
  ['Filename', snapshot.fileName],
2935
- ['Format', snapshot.sourceFormat === 'mdz' ? 'MDZ package' : 'Markdown'],
3626
+ ['Format', isMdz ? 'MDZ package' : 'Markdown'],
3627
+ // For .mdz, archiveBytes really is what a save right now would write —
3628
+ // current in-memory bytes, edits included, not a stale re-read of disk.
3629
+ // For plain Markdown, archiveBytes is *not* that: it's some internally
3630
+ // wrapped representation with its own fixed overhead (verified: a
3631
+ // 5-byte markdown document reported 462 "archive" bytes) — use the
3632
+ // actual text's encoded size instead, which is what a .md save writes.
3633
+ ['Size', formatByteSize(isMdz ? snapshot.archiveBytes.length : new TextEncoder().encode(snapshot.currentText).length)],
2936
3634
  ['Document title', snapshot.displayTitle],
2937
3635
  ['First heading', snapshot.headingFallback ?? 'Not found'],
2938
3636
  ['Created', formatMetadataValue(manifest?.created)],
2939
3637
  ['Modified', formatMetadataValue(manifest?.modified)],
2940
- ['Entry point', snapshot.sourceFormat === 'mdz' ? snapshot.content.entryPoint : 'Not applicable']
3638
+ ['Entry point', isMdz ? snapshot.content.entryPoint : 'Not applicable'],
3639
+ ['Documents', isMdz ? String(snapshot.workspace.documents.length) : 'Not applicable'],
3640
+ ['Assets', isMdz ? String(snapshot.workspace.assets.length) : 'Not applicable']
2941
3641
  ];
3642
+ // Only shown when actually read-only — most documents are editable, and a
3643
+ // "Read-only: No" row for the common case would just be noise. Spelled
3644
+ // out as a filesystem condition rather than an editor state: this mode is
3645
+ // driven entirely by the host (see MdzipWorkspaceOpenOptions.mode) — most
3646
+ // often because a host checked the file's OS/disk permissions, as the
3647
+ // vscode extension does — not something toggled inside the editor itself,
3648
+ // so the wording should point users at their file, not at this UI.
3649
+ if (snapshot.mode === 'read-only') {
3650
+ fields.splice(1, 0, ['Read-only', 'Yes — the file on disk (or its host) is not writable']);
3651
+ }
2942
3652
  this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
2943
3653
  return this.createMetadataRow(label, value);
2944
3654
  }));
@@ -3254,6 +3964,61 @@ export class MdzipWorkspaceView {
3254
3964
  this.cmEditor.focus();
3255
3965
  }
3256
3966
  }
3967
+ /**
3968
+ * Handles a click on an existing image's edit affordance (see
3969
+ * `imageEditAffordanceField`/the `click` domEventHandler in
3970
+ * createCmEditor). Only reachable when `imageEditHandler` is set — the
3971
+ * click handler that dispatches the affordance already gates on that.
3972
+ */
3973
+ async openImageEditFlow(from, to) {
3974
+ const editor = this.cmEditor;
3975
+ const handler = this.options.imageEditHandler;
3976
+ if (!editor || !handler) {
3977
+ return;
3978
+ }
3979
+ const parsed = findImageReferenceAtOffset(editor.state, from);
3980
+ if (!parsed || parsed.from !== from || parsed.to !== to) {
3981
+ return;
3982
+ }
3983
+ const request = {
3984
+ src: parsed.src,
3985
+ altText: parsed.altText,
3986
+ width: parsed.width,
3987
+ height: parsed.height,
3988
+ position: parsed.position,
3989
+ mode: parsed.kind
3990
+ };
3991
+ let decision;
3992
+ try {
3993
+ decision = normalizeImageInsertDecision(await handler(request));
3994
+ }
3995
+ catch (error) {
3996
+ this.options.onFailed?.(error);
3997
+ decision = null;
3998
+ }
3999
+ this.cmEditor?.dispatch({ effects: imageEditAffordanceEffect.of(null) });
4000
+ if (!decision) {
4001
+ return; // cancelled
4002
+ }
4003
+ const current = this.cmEditor;
4004
+ if (!current) {
4005
+ return;
4006
+ }
4007
+ // The handler may have awaited arbitrarily long — revalidate against the
4008
+ // live doc instead of trusting the captured range/text, in case it
4009
+ // changed underneath while the dialog was open.
4010
+ const revalidated = findImageReferenceAtOffset(current.state, from);
4011
+ if (!revalidated || revalidated.raw !== parsed.raw) {
4012
+ this.options.onFailed?.(new Error('Image reference changed before the edit could be applied.'));
4013
+ return;
4014
+ }
4015
+ const replacement = formatImageEditMarkdown(revalidated.src, decision);
4016
+ current.dispatch({
4017
+ changes: { from: revalidated.from, to: revalidated.to, insert: replacement },
4018
+ selection: { anchor: revalidated.from + replacement.length }
4019
+ });
4020
+ current.focus();
4021
+ }
3257
4022
  async resolveImageInsertDecision(bytes, mimeType, options) {
3258
4023
  const size = sniffImageSize(bytes, mimeType);
3259
4024
  const request = {
@@ -3550,7 +4315,7 @@ export class MdzipWorkspaceView {
3550
4315
  }
3551
4316
  }
3552
4317
  // Items for the rendered-preview selection menu. Taking over `contextmenu`
3553
- // to offer Select All also suppresses the browser's native menu — which is
4318
+ // to offer Copy All also suppresses the browser's native menu — which is
3554
4319
  // the only thing that was otherwise offering Copy on a right-click — so
3555
4320
  // Copy has to be reinstated explicitly whenever there's a selection.
3556
4321
  previewMenuItems() {
@@ -3564,11 +4329,16 @@ export class MdzipWorkspaceView {
3564
4329
  items.push(null);
3565
4330
  }
3566
4331
  items.push({
3567
- action: 'preview-select-all',
3568
- label: 'Select All',
3569
- icon: MENU_SELECT_ALL_ICON_HTML,
4332
+ action: 'preview-copy-all',
4333
+ label: 'Copy All',
4334
+ icon: MENU_COPY_ICON_HTML,
3570
4335
  shortcut: this.editorShortcut('A')
3571
4336
  });
4337
+ items.push({
4338
+ action: 'preview-copy-all-images',
4339
+ label: 'Copy All with Images',
4340
+ icon: MENU_COPY_ICON_HTML
4341
+ });
3572
4342
  return items;
3573
4343
  }
3574
4344
  handlePreviewMenuAction(action) {
@@ -3579,41 +4349,448 @@ export class MdzipWorkspaceView {
3579
4349
  return;
3580
4350
  }
3581
4351
  switch (action) {
3582
- case 'preview-select-all':
3583
- this.selectAllPreviewContent();
4352
+ case 'preview-copy-all':
4353
+ void this.copyAllPreviewContent();
4354
+ break;
4355
+ case 'preview-copy-all-images':
4356
+ void this.copyAllWithImagesPreviewContent();
3584
4357
  break;
3585
4358
  case 'preview-copy':
3586
- void this.copyPreviewSelection(state.text);
4359
+ // Partial Copy never shows the render dialog (there's nothing to
4360
+ // drain), so its confirmation is always the brief toast.
4361
+ void this.copyPreviewSelection(state.text).then((outcome) => this.finishCopyNotification(false, outcome));
3587
4362
  break;
3588
4363
  }
3589
4364
  }
4365
+ /**
4366
+ * Shows the copy confirmation either as a brief auto-hiding toast (an
4367
+ * instant copy — nothing for the user to have looked away from) or, if
4368
+ * the render dialog was showing, by switching that same dialog into a
4369
+ * dismissable "done" state instead of hiding it — a copy that took long
4370
+ * enough to need a progress dialog shouldn't end in a 2-second toast the
4371
+ * user may not be looking at. `outcome` null means there was nothing to
4372
+ * copy (empty selection) — silently clean up the dialog if one was
4373
+ * showing, no confirmation needed. An `error` outcome carries the actual
4374
+ * failure text inline (`onFailed` is a host callback with no guaranteed
4375
+ * visible surface — this component has no other way to guarantee the
4376
+ * user ever sees why it failed).
4377
+ */
4378
+ finishCopyNotification(dialogWasShown, outcome) {
4379
+ if (!outcome) {
4380
+ if (dialogWasShown) {
4381
+ this.copyRenderDialogState = null;
4382
+ this.render();
4383
+ }
4384
+ return;
4385
+ }
4386
+ const text = 'error' in outcome ? `Copy failed: ${outcome.error}` : outcome.message;
4387
+ if (dialogWasShown) {
4388
+ this.copyRenderDialogState = null;
4389
+ this.copyRenderDoneState = { message: text };
4390
+ this.render();
4391
+ }
4392
+ else {
4393
+ this.showCopyToast(text);
4394
+ }
4395
+ }
4396
+ /** 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. */
4397
+ armCopyReady(perform) {
4398
+ this.copyRenderDialogState = null;
4399
+ this.copyReadyState = { perform };
4400
+ this.render();
4401
+ }
4402
+ /** 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. */
4403
+ async performReadyCopy() {
4404
+ const ready = this.copyReadyState;
4405
+ if (!ready)
4406
+ return;
4407
+ this.copyReadyState = null;
4408
+ const outcome = await ready.perform();
4409
+ // Always the dialog path: copyReadyState only ever gets armed for a
4410
+ // copy that already showed the progress dialog.
4411
+ this.finishCopyNotification(true, outcome);
4412
+ }
4413
+ /**
4414
+ * Races `promise` against a timer, rejecting with `timeoutMessage` if the
4415
+ * timer wins. The async Clipboard API has no built-in timeout, and a
4416
+ * write large enough to strain a real OS clipboard (a document with
4417
+ * thousands of embedded images can build a HTML payload well over
4418
+ * 100MB) can apparently hang indefinitely on some machines rather than
4419
+ * rejecting — without this, that leaves the operation permanently
4420
+ * pending, and the progress dialog never resolves into either a done or
4421
+ * a failure state.
4422
+ */
4423
+ async withTimeout(promise, timeoutMs, timeoutMessage) {
4424
+ let timer;
4425
+ const timeout = new Promise((_, reject) => {
4426
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
4427
+ });
4428
+ try {
4429
+ return await Promise.race([promise, timeout]);
4430
+ }
4431
+ finally {
4432
+ clearTimeout(timer);
4433
+ }
4434
+ }
4435
+ /** 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). */
3590
4436
  async copyPreviewSelection(text) {
3591
4437
  if (!text) {
3592
- return;
4438
+ return null;
3593
4439
  }
3594
4440
  try {
3595
- await this.editorClipboard()?.writeText(text);
4441
+ await this.withTimeout(Promise.resolve(this.editorClipboard()?.writeText(text)), this.clipboardFallbackWriteTimeoutMs, 'Clipboard write timed out.');
4442
+ return { message: 'Plain text copied to clipboard' };
3596
4443
  }
3597
4444
  catch (error) {
3598
4445
  this.options.onFailed?.(error);
4446
+ return { error: error instanceof Error ? error.message : String(error) };
3599
4447
  }
3600
4448
  }
3601
- // Selects the full rendered content of the preview pane, scoped to that
3602
- // pane rather than the whole page (native Ctrl+A / right-click Select All
3603
- // would otherwise grab the source editor's text too in split layout).
3604
- selectAllPreviewContent() {
3605
- const doc = this.elPreviewContent.ownerDocument;
3606
- const selection = doc.defaultView?.getSelection();
3607
- if (!selection) {
4449
+ /** 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. */
4450
+ updateCopyRenderDialogProgress() {
4451
+ if (!this.copyRenderDialogState) {
3608
4452
  return;
3609
4453
  }
3610
- // Focus before selecting: focusing the pane after building the range
3611
- // collapses the selection right back out.
3612
- this.elPreviewPane.focus({ preventScroll: true });
3613
- const range = doc.createRange();
3614
- range.selectNodeContents(this.elPreviewContent);
3615
- selection.removeAllRanges();
3616
- selection.addRange(range);
4454
+ const { done, total, label } = this.copyRenderDialogState;
4455
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
4456
+ this.elCopyRenderProgressBar.style.width = `${percent}%`;
4457
+ this.elCopyRenderProgressText.textContent = total > 0 ? `${label} (${done} / ${total})...` : `${label}...`;
4458
+ }
4459
+ /**
4460
+ * Copies the entire rendered document as plain text (same fidelity as
4461
+ * `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
4462
+ * much of it is currently mounted under progressive rendering. If
4463
+ * everything's already mounted (small doc, non-chunked render, or the user
4464
+ * already scrolled through it) this is instant — `chunkedRenderState` is
4465
+ * null in exactly that case. Otherwise it force-drains the rest first,
4466
+ * showing a cancelable progress dialog once the wait clears a short
4467
+ * debounce so fast documents never flicker it into view.
4468
+ */
4469
+ async copyAllPreviewContent() {
4470
+ const pending = this.chunkedRenderState;
4471
+ if (!pending) {
4472
+ const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
4473
+ this.finishCopyNotification(false, outcome);
4474
+ return;
4475
+ }
4476
+ const total = pending.chunks.length;
4477
+ const abort = new AbortController();
4478
+ let dialogShown = false;
4479
+ const label = 'Rendering the full document';
4480
+ const showDialogTimer = setTimeout(() => {
4481
+ dialogShown = true;
4482
+ this.copyRenderDialogState = { done: pending.cursor, total, label, abort };
4483
+ this.render();
4484
+ }, 200);
4485
+ // Progress ticks update the dialog's progress bar/text directly instead
4486
+ // of going through the view's full `render()` — profiling a drain on a
4487
+ // 15,000-image document showed `render()` alone (it re-syncs the whole
4488
+ // shell, e.g. rebuilding the nav tree) costing ~870ms of self-time over
4489
+ // a 4s sample even throttled to 10/sec, dwarfing the actual chunk-mount
4490
+ // work it was meant to make room for.
4491
+ await this.drainRemainingChunks(pending, (done) => {
4492
+ if (!dialogShown)
4493
+ return;
4494
+ this.copyRenderDialogState = { done, total, label, abort };
4495
+ this.updateCopyRenderDialogProgress();
4496
+ }, abort.signal);
4497
+ clearTimeout(showDialogTimer);
4498
+ if (abort.signal.aborted) {
4499
+ if (dialogShown) {
4500
+ this.copyRenderDialogState = null;
4501
+ this.render();
4502
+ }
4503
+ // Cancelled partway through — whatever's left stays unmounted, so
4504
+ // re-arm the usual scroll-driven continuation for it.
4505
+ const remaining = this.chunkedRenderState;
4506
+ if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle) {
4507
+ this.armChunkSentinel(remaining.chunks, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
4508
+ }
4509
+ return;
4510
+ }
4511
+ if (dialogShown) {
4512
+ // The prepare phase above can take anywhere from seconds to minutes —
4513
+ // long enough that the click which started this has lost its user
4514
+ // activation by now, so the actual write waits for a fresh one
4515
+ // (the dialog's own Copy button) instead of firing automatically.
4516
+ // See copyReadyState's doc comment.
4517
+ this.armCopyReady(() => this.copyPreviewSelection(this.elPreviewContent.textContent ?? ''));
4518
+ return;
4519
+ }
4520
+ const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
4521
+ this.finishCopyNotification(false, outcome);
4522
+ }
4523
+ /**
4524
+ * Renders the *entire* current document to one HTML string, independent
4525
+ * of whatever's mounted in `elPreviewContent` — no DOM reads or writes.
4526
+ * Copy All with Images needs pristine `<img src="original/path">` markup
4527
+ * to hand to `MdzipAssetSession.rewriteHtmlEmbeddingImages`, and an
4528
+ * already-mounted, possibly-hydrated `<img>` in the live preview may have
4529
+ * had its `src` swapped for a `blob:` URL or stripped entirely pending lazy
4530
+ * load — either way the original archive path is gone. Time-budgeted and
4531
+ * yielded like `renderAndMountChunkBatch`, for the same reason: some
4532
+ * chunks cost far more to render+sanitize than others. Extension `mount()`
4533
+ * hooks are not run — they render into a live DOM (e.g. mermaid diagrams
4534
+ * turning marked-up code fences into SVG), which this never touches, so a
4535
+ * mermaid diagram will paste as its pre-render markup, not a rendered
4536
+ * diagram. Only reachable when `renderingService.supportsChunking` is
4537
+ * true; the caller checks that first.
4538
+ */
4539
+ async renderFullDocumentHtml(snapshot, context, signal, cacheGeneration, onProgress) {
4540
+ const tokensResult = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
4541
+ const tokens = Array.isArray(tokensResult) ? tokensResult : await tokensResult;
4542
+ if (signal.aborted) {
4543
+ throw new DOMException('Rendering aborted.', 'AbortError');
4544
+ }
4545
+ // groupTokensIntoChunks is also called with no options in
4546
+ // renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
4547
+ // call sites' chunking in sync, since chunk index is chunkHtmlCache's
4548
+ // only key.
4549
+ const chunks = groupTokensIntoChunks(tokens);
4550
+ const view = this.elPreviewContent.ownerDocument.defaultView;
4551
+ const clock = view?.performance ?? performance;
4552
+ const BATCH_TIME_BUDGET_MS = 10;
4553
+ let html = '';
4554
+ let cursor = 0;
4555
+ while (cursor < chunks.length) {
4556
+ if (signal.aborted) {
4557
+ throw new DOMException('Rendering aborted.', 'AbortError');
4558
+ }
4559
+ const batchStart = clock.now();
4560
+ let processedInBatch = 0;
4561
+ while (cursor < chunks.length && (processedInBatch === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
4562
+ const chunkIndex = cursor;
4563
+ const cacheHit = cacheGeneration !== null && this.chunkHtmlCache?.generation === cacheGeneration
4564
+ ? this.chunkHtmlCache.html.get(chunkIndex)
4565
+ : undefined;
4566
+ let chunkHtml;
4567
+ if (cacheHit !== undefined) {
4568
+ chunkHtml = cacheHit;
4569
+ }
4570
+ else {
4571
+ const result = this.renderingService.renderChunk(chunks[chunkIndex], context);
4572
+ chunkHtml = typeof result === 'string' ? result : await result;
4573
+ // Only populate the shared cache if this generation is *still*
4574
+ // the live one at the moment this (possibly async) render
4575
+ // finished — checked per-chunk, not once up front, since the
4576
+ // outer loop yields via requestAnimationFrame and individual
4577
+ // chunk renders (mermaid) can themselves be async. If the
4578
+ // document was edited mid-copy, cacheGeneration is now stale
4579
+ // even though this render (for the snapshot Copy All captured)
4580
+ // legitimately continues to completion — never write it back
4581
+ // under a generation number that's no longer current.
4582
+ if (cacheGeneration !== null && cacheGeneration === this.previewGeneration) {
4583
+ if (this.chunkHtmlCache?.generation !== cacheGeneration) {
4584
+ this.chunkHtmlCache = { generation: cacheGeneration, html: new Map() };
4585
+ }
4586
+ this.chunkHtmlCache.html.set(chunkIndex, chunkHtml);
4587
+ }
4588
+ }
4589
+ html += chunkHtml;
4590
+ cursor += 1;
4591
+ processedInBatch += 1;
4592
+ }
4593
+ onProgress?.(cursor, chunks.length);
4594
+ if (cursor >= chunks.length) {
4595
+ break;
4596
+ }
4597
+ if (signal.aborted) {
4598
+ throw new DOMException('Rendering aborted.', 'AbortError');
4599
+ }
4600
+ await new Promise((resolve) => {
4601
+ if (view?.requestAnimationFrame)
4602
+ view.requestAnimationFrame(() => resolve());
4603
+ else
4604
+ setTimeout(resolve, 0);
4605
+ });
4606
+ }
4607
+ return html;
4608
+ }
4609
+ /**
4610
+ * Like `copyAllPreviewContent`, but writes a rich `text/html` clipboard
4611
+ * representation (alongside the same `text/plain` fallback) with every
4612
+ * archive image re-embedded as a self-contained `data:` URL — the format
4613
+ * an external app like Word needs, since this document's own `blob:` URLs
4614
+ * only resolve inside this tab. Two phases share one debounced, cancelable
4615
+ * dialog: render the document fresh (`renderFullDocumentHtml`), then embed
4616
+ * its images (`rewriteHtmlEmbeddingImages`). Falls back to
4617
+ * `copyAllPreviewContent`'s plain-text-only behavior when there's no
4618
+ * default renderer to re-render from (a host-supplied custom renderer,
4619
+ * the same escape hatch `progressiveTextRendering` already has) — or, if
4620
+ * the clipboard rejects the rich write for any reason (unsupported
4621
+ * browser, payload too large), falls back to a plain-text write so the
4622
+ * user still gets *something* rather than nothing.
4623
+ */
4624
+ async copyAllWithImagesPreviewContent() {
4625
+ const snapshot = this.workspace?.snapshot();
4626
+ if (!snapshot || snapshot.currentPathType !== 'markdown' || !this.renderingService.supportsChunking) {
4627
+ await this.copyAllPreviewContent();
4628
+ return;
4629
+ }
4630
+ const abort = new AbortController();
4631
+ let dialogShown = false;
4632
+ const showDialogTimer = setTimeout(() => {
4633
+ dialogShown = true;
4634
+ this.copyRenderDialogState = { done: 0, total: 0, label: 'Rendering the document', abort };
4635
+ this.render();
4636
+ }, 200);
4637
+ const updateProgress = (done, total, label) => {
4638
+ if (!dialogShown)
4639
+ return;
4640
+ this.copyRenderDialogState = { done, total, label, abort };
4641
+ this.updateCopyRenderDialogProgress();
4642
+ };
4643
+ let finalHtml;
4644
+ let imageCount = 0;
4645
+ try {
4646
+ const context = this.createMarkdownContext(snapshot, abort.signal);
4647
+ // Only trust chunkHtmlCache if the live preview generation still
4648
+ // represents this exact snapshot — if the preview hasn't caught up to
4649
+ // it yet, or the document has since changed, render fully fresh
4650
+ // rather than risk splicing in HTML for different content.
4651
+ const cacheGeneration = this.previewMemoMatchesSnapshot(snapshot) ? this.previewGeneration : null;
4652
+ let html = await this.renderFullDocumentHtml(snapshot, context, abort.signal, cacheGeneration, (done, total) => updateProgress(done, total, 'Rendering the document'));
4653
+ if (this.assetSession) {
4654
+ html = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, abort.signal, (done, total) => {
4655
+ imageCount = total;
4656
+ updateProgress(done, total, 'Embedding images');
4657
+ });
4658
+ }
4659
+ finalHtml = html;
4660
+ }
4661
+ catch (error) {
4662
+ clearTimeout(showDialogTimer);
4663
+ if (error?.name === 'AbortError') {
4664
+ // Deliberate cancel — just close, no failure to report.
4665
+ if (dialogShown) {
4666
+ this.copyRenderDialogState = null;
4667
+ this.render();
4668
+ }
4669
+ }
4670
+ else {
4671
+ this.finishCopyNotification(dialogShown, { error: error instanceof Error ? error.message : String(error) });
4672
+ this.options.onFailed?.(error);
4673
+ }
4674
+ return;
4675
+ }
4676
+ clearTimeout(showDialogTimer);
4677
+ const scratch = this.elPreviewContent.ownerDocument.createElement('div');
4678
+ scratch.innerHTML = finalHtml;
4679
+ const plainText = scratch.textContent ?? '';
4680
+ if (dialogShown) {
4681
+ // Same reasoning as copyAllPreviewContent: the prepare phase (often
4682
+ // the slower of the two here, once image embedding is involved) has
4683
+ // long since burned through the click's user activation.
4684
+ this.armCopyReady(() => this.writeRichClipboard(finalHtml, plainText, imageCount));
4685
+ return;
4686
+ }
4687
+ const outcome = await this.writeRichClipboard(finalHtml, plainText, imageCount);
4688
+ this.finishCopyNotification(false, outcome);
4689
+ }
4690
+ /**
4691
+ * Writes an HTML+plain-text `ClipboardItem` when the browser supports it,
4692
+ * falling back to a plain-text-only `writeText` (same as
4693
+ * `copyPreviewSelection`, which reports itself as "Plain text") when it
4694
+ * doesn't, or if the rich write itself throws (e.g. a payload too large
4695
+ * for the OS clipboard) — either way the user ends up with *something* on
4696
+ * their clipboard rather than nothing. Returns the confirmation message
4697
+ * for the caller to show (see `finishCopyNotification`), naming the
4698
+ * actual MIME type that ended up on the clipboard ("HTML" for a
4699
+ * successful rich write, "Plain text" for the fallback) so the user knows
4700
+ * what they're about to paste — `imageCount` only adds to the "HTML"
4701
+ * wording, since even a rich write with zero images is still HTML, not
4702
+ * plain text.
4703
+ */
4704
+ async writeRichClipboard(html, plainText, imageCount) {
4705
+ const clipboard = this.editorClipboard();
4706
+ const view = this.elPreviewContent.ownerDocument.defaultView;
4707
+ const ClipboardItemCtor = view?.ClipboardItem;
4708
+ if (clipboard && ClipboardItemCtor && 'write' in clipboard) {
4709
+ try {
4710
+ const item = new ClipboardItemCtor({
4711
+ 'text/html': new Blob([html], { type: 'text/html' }),
4712
+ 'text/plain': new Blob([plainText], { type: 'text/plain' })
4713
+ });
4714
+ // A document with thousands of repeated images (e.g. an avatar
4715
+ // reused across every row of a chat export) can build a payload
4716
+ // well over 100MB, since each occurrence embeds its own full copy
4717
+ // — the clipboard format has no way to reference a shared resource.
4718
+ // 30s is generous for a legitimate slow write; the real purpose is
4719
+ // making sure this can't hang forever with the dialog stuck mid-copy.
4720
+ await this.withTimeout(clipboard.write([item]), this.clipboardWriteTimeoutMs, 'Clipboard write timed out.');
4721
+ return {
4722
+ message: imageCount > 0
4723
+ ? `HTML with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
4724
+ : 'HTML copied to clipboard'
4725
+ };
4726
+ }
4727
+ catch (error) {
4728
+ this.options.onFailed?.(error);
4729
+ // Fall through to the plain-text fallback rather than surfacing
4730
+ // this error directly — if that also fails, its error is more
4731
+ // relevant (it's the one actually blocking the user from getting
4732
+ // anything at all), and the plain-text path already reports this
4733
+ // one to onFailed for anyone watching that.
4734
+ }
4735
+ }
4736
+ return await this.copyPreviewSelection(plainText);
4737
+ }
4738
+ /**
4739
+ * Mounts every chunk from `state.cursor` onward, yielding to a fresh
4740
+ * animation frame between batches (unlike the sentinel path's rAF-time-
4741
+ * budgeted-per-batch loop, this has no viewport to wait on — it has to
4742
+ * plow through the whole rest of the document, so the explicit yield is
4743
+ * what keeps a huge draw from locking up the tab while it does). Stops
4744
+ * early if `signal` aborts, a newer render generation supersedes this one,
4745
+ * or the chunk state's own render context aborts.
4746
+ *
4747
+ * Deliberately uses `renderAndMountChunkBatch` instead of `mountChunkBatch`
4748
+ * and defers image hydration until the whole drain finishes, running it
4749
+ * once over every image the drain mounted, instead of once per batch:
4750
+ * Copy All only needs the mounted chunks' *text* (`elPreviewContent
4751
+ * .textContent`, which images never contribute to), so there's no reason
4752
+ * to wait on `hydrateImages`' async image resolution at all here — and
4753
+ * racing through batches as fast as this loop can while each one fires
4754
+ * its own independent, un-awaited `hydrateImages` background loop (as
4755
+ * `mountChunkBatch` does for the scroll-paced callers, where that's fine —
4756
+ * scrolling naturally rate-limits how many can ever be in flight at once)
4757
+ * lets dozens of those loops pile up concurrently on an image-heavy
4758
+ * document, each spending its own `CHUNK_BUDGET_MS` in the same frame —
4759
+ * measured 100-200ms frame gaps on a 15,000-image document. One combined
4760
+ * pass over every image at the end avoids the pile-up entirely, without
4761
+ * making Copy All wait on it.
4762
+ */
4763
+ async drainRemainingChunks(state, onProgress, signal) {
4764
+ const { chunks, context, generation, animateImageHydration } = state;
4765
+ // Draining is instead of the scroll-driven continuation, not alongside
4766
+ // it — tearing down any armed sentinel first stops the two from racing
4767
+ // and double-mounting the same chunk.
4768
+ state.sentinelHandle?.destroy();
4769
+ if (this.chunkedRenderState?.generation === generation) {
4770
+ this.chunkedRenderState.sentinelHandle = null;
4771
+ }
4772
+ const allPending = [];
4773
+ try {
4774
+ while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < chunks.length) {
4775
+ if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
4776
+ return;
4777
+ const cursor = this.chunkedRenderState.cursor;
4778
+ const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, cursor, context, generation);
4779
+ if (generation !== this.previewGeneration || context.signal.aborted)
4780
+ return;
4781
+ allPending.push(...this.collectPendingImages(mountedRoots, animateImageHydration));
4782
+ this.recordChunkProgress(generation, chunks, newCursor);
4783
+ onProgress(newCursor, chunks.length);
4784
+ if (signal.aborted)
4785
+ return;
4786
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
4787
+ }
4788
+ }
4789
+ finally {
4790
+ if (generation === this.previewGeneration && !context.signal.aborted) {
4791
+ this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
4792
+ }
4793
+ }
3617
4794
  }
3618
4795
  // Inserts a fenced code block, carrying the chosen language as the fence info
3619
4796
  // string. An empty language produces a plain ```` ``` ```` block.
@@ -4012,6 +5189,14 @@ export class MdzipWorkspaceView {
4012
5189
  setZoom(value) {
4013
5190
  this.zoom = Math.max(0.5, Math.min(2.5, Math.round(value * 100) / 100));
4014
5191
  this.render();
5192
+ // render() only updates the --mdz-zoom CSS variable; CodeMirror caches
5193
+ // gutter/line-block heights from its own measure pass and has no way to
5194
+ // observe a custom-property change (font-size grows but the scroller's
5195
+ // own outer box doesn't, so no ResizeObserver fires either). Without
5196
+ // this, gutter row heights stay pinned to their pre-zoom pixel values
5197
+ // while .cm-line rows reflow normally, drifting the two further apart
5198
+ // with every line.
5199
+ this.cmEditor?.requestMeasure();
4015
5200
  }
4016
5201
  /**
4017
5202
  * Sets the active color scheme after construction.
@@ -4611,8 +5796,29 @@ export class MdzipWorkspaceView {
4611
5796
  if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
4612
5797
  return;
4613
5798
  }
4614
- this.syncing = true;
4615
5799
  const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
5800
+ // The editor is at its true bottom, but under progressive rendering the
5801
+ // preview's scrollHeight only accounts for chunks mounted so far — a
5802
+ // plain ratio jump would only reach the bottom of whatever happens to be
5803
+ // mounted right now, not the document's actual end. Force-drain the rest
5804
+ // first so this edge is reliable regardless of how much has been
5805
+ // scrolled-into-view on the preview side.
5806
+ //
5807
+ // "At the bottom" is detected via CodeMirror's own viewport, not a
5808
+ // scrollHeight/scrollTop pixel comparison: CodeMirror estimates the
5809
+ // height of not-yet-measured (virtualized) lines, and on a huge document
5810
+ // that estimate can be tens of pixels off from where it actually clamps
5811
+ // scrollTop — confirmed live against a real 88,000-line file, where a
5812
+ // small fixed pixel epsilon never matched. `viewport.to` is what
5813
+ // CodeMirror has actually decided to draw for the current scroll
5814
+ // position, so comparing it to the document length is exact regardless
5815
+ // of any height estimation drift.
5816
+ const atDocEnd = this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
5817
+ if (atDocEnd && this.chunkedRenderState) {
5818
+ void this.syncScrollToPreviewBottom();
5819
+ return;
5820
+ }
5821
+ this.syncing = true;
4616
5822
  const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
4617
5823
  const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
4618
5824
  const target = scrollRatio * previewHeight;
@@ -4620,6 +5826,84 @@ export class MdzipWorkspaceView {
4620
5826
  this.elPreviewPane.scrollTop = target;
4621
5827
  this.syncing = false;
4622
5828
  }
5829
+ /**
5830
+ * Handles syncScrollToPreview's bottom edge when the preview still has
5831
+ * unmounted chunks: force-mounts the rest (same drain Copy All uses) so
5832
+ * the preview's scrollHeight reflects the whole document, then jumps to
5833
+ * its real bottom — instead of the ordinary ratio-based jump, which would
5834
+ * only land at the bottom of whatever was mounted the instant the sync
5835
+ * fired. Re-checks that the editor is still at its bottom and the document
5836
+ * hasn't changed before applying the jump, since the drain can take long
5837
+ * enough on a huge document for either to no longer hold.
5838
+ *
5839
+ * On the most extreme real documents this drain has been measured at
5840
+ * ~106s (thousands of chunks, tens of thousands of images each needing a
5841
+ * real DOM slot + IntersectionObserver registration) — long enough that a
5842
+ * silent wait looks indistinguishable from a frozen preview pane. Past a
5843
+ * short debounce (so ordinary documents never see it), a small status
5844
+ * toast shows progress, reusing the same element Copy All's confirmation
5845
+ * messages use.
5846
+ */
5847
+ async syncScrollToPreviewBottom() {
5848
+ const state = this.chunkedRenderState;
5849
+ if (!state || this.bottomDrainGeneration === state.generation) {
5850
+ return;
5851
+ }
5852
+ const generation = state.generation;
5853
+ this.bottomDrainGeneration = generation;
5854
+ if (this.copyToastHideTimer) {
5855
+ clearTimeout(this.copyToastHideTimer);
5856
+ this.copyToastHideTimer = null;
5857
+ }
5858
+ let showToastTimer = setTimeout(() => {
5859
+ showToastTimer = null;
5860
+ this.scrollCatchUpState = { done: state.cursor, total: state.chunks.length };
5861
+ this.updateScrollCatchUpToast();
5862
+ }, 200);
5863
+ try {
5864
+ await this.drainRemainingChunks(state, (done, total) => {
5865
+ if (!this.scrollCatchUpState)
5866
+ return;
5867
+ this.scrollCatchUpState = { done, total };
5868
+ this.updateScrollCatchUpToast();
5869
+ }, new AbortController().signal);
5870
+ }
5871
+ finally {
5872
+ if (showToastTimer) {
5873
+ clearTimeout(showToastTimer);
5874
+ }
5875
+ if (this.scrollCatchUpState) {
5876
+ this.scrollCatchUpState = null;
5877
+ this.elCopyToast.hidden = true;
5878
+ }
5879
+ if (this.bottomDrainGeneration === generation) {
5880
+ this.bottomDrainGeneration = null;
5881
+ }
5882
+ }
5883
+ if (generation !== this.previewGeneration || this.layout !== 'split' || !this.cmEditor) {
5884
+ return;
5885
+ }
5886
+ // See the comment in syncScrollToPreview on why this is viewport-based
5887
+ // rather than a scrollHeight/scrollTop pixel comparison.
5888
+ if (this.cmEditor.viewport.to < this.cmEditor.state.doc.length) {
5889
+ return;
5890
+ }
5891
+ this.syncing = true;
5892
+ const target = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
5893
+ this.lastSyncedPreviewScrollTop = target;
5894
+ this.elPreviewPane.scrollTop = target;
5895
+ this.syncing = false;
5896
+ }
5897
+ /** Syncs the catch-up toast's text from `scrollCatchUpState`, bypassing `render()` — see `updateCopyRenderDialogProgress` for why. No-op while the toast isn't showing. */
5898
+ updateScrollCatchUpToast() {
5899
+ if (!this.scrollCatchUpState) {
5900
+ return;
5901
+ }
5902
+ const { done, total } = this.scrollCatchUpState;
5903
+ const percent = total > 0 ? Math.round((done / total) * 100) : 0;
5904
+ this.elCopyToast.textContent = total > 0 ? `Catching up the preview... (${percent}%)` : 'Catching up the preview...';
5905
+ this.elCopyToast.hidden = false;
5906
+ }
4623
5907
  }
4624
5908
  function canShowSourceLayout(snapshot) {
4625
5909
  return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
@@ -4744,8 +6028,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
4744
6028
  : after.startsWith('\n') ? '\n' : '\n\n';
4745
6029
  return `${prefix}${html}${suffix}`;
4746
6030
  }
4747
- function escapeMarkdownImageAlt(value) {
4748
- return value.replace(/\\/g, '\\\\').replace(/]/g, '\\]');
6031
+ function formatByteSize(bytes) {
6032
+ if (!Number.isFinite(bytes) || bytes < 0) {
6033
+ return 'Not available';
6034
+ }
6035
+ if (bytes < 1024) {
6036
+ return `${bytes} B`;
6037
+ }
6038
+ const units = ['KB', 'MB', 'GB'];
6039
+ let value = bytes / 1024;
6040
+ let unitIndex = 0;
6041
+ while (value >= 1024 && unitIndex < units.length - 1) {
6042
+ value /= 1024;
6043
+ unitIndex += 1;
6044
+ }
6045
+ // One decimal below 10 of a unit (e.g. "1.4 MB"), none above (e.g. "23 MB") —
6046
+ // matches how OS file managers commonly round these.
6047
+ const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString();
6048
+ return `${formatted} ${units[unitIndex]}`;
4749
6049
  }
4750
6050
  function formatMetadataValue(value) {
4751
6051
  if (typeof value === 'string' && value.trim()) {
@@ -5005,6 +6305,30 @@ const SHELL_HTML = `
5005
6305
  </div>
5006
6306
  </div>
5007
6307
 
6308
+ <div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
6309
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
6310
+ <div class="title-dialog copy-render-dialog">
6311
+ <h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
6312
+ <div data-ref="copy-render-progress-section">
6313
+ <p data-ref="copy-render-progress-text">Rendering the full document...</p>
6314
+ <div class="copy-render-progress-track">
6315
+ <div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
6316
+ </div>
6317
+ </div>
6318
+ <p data-ref="copy-render-ready-text" hidden>
6319
+ The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
6320
+ click Copy to finish.
6321
+ </p>
6322
+ <p data-ref="copy-render-done-text" hidden></p>
6323
+ <div class="title-dialog-actions">
6324
+ <button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
6325
+ <button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
6326
+ <button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
6327
+ <button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
6328
+ </div>
6329
+ </div>
6330
+ </div>
6331
+
5008
6332
  <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
5009
6333
  role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
5010
6334
  <div class="title-dialog metadata-dialog">
@@ -5049,6 +6373,8 @@ const SHELL_HTML = `
5049
6373
 
5050
6374
  <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
5051
6375
 
6376
+ <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
6377
+
5052
6378
  <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
5053
6379
  </section>
5054
6380
  `;