@mdzip/editor 1.3.21 → 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 +27 -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 +45 -0
  42. package/dist/view-css.js.map +1 -1
  43. package/dist/view.d.ts +319 -1
  44. package/dist/view.d.ts.map +1 -1
  45. package/dist/view.js +1371 -92
  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`);
@@ -433,6 +435,29 @@ const mdzipEditorTheme = EditorView.theme({
433
435
  color: 'var(--mdzip-muted-foreground-color)',
434
436
  opacity: '0.65',
435
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
+ },
436
461
  '.cm-panels': {
437
462
  background: 'var(--mdzip-widget-background-color)',
438
463
  color: 'var(--mdzip-editor-foreground-color)',
@@ -555,6 +580,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
555
580
  }, {
556
581
  decorations: value => value.decorations
557
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
+ });
558
677
  function injectStyles(doc) {
559
678
  const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
560
679
  if (existing) {
@@ -738,6 +857,53 @@ export class MdzipWorkspaceView {
738
857
  // trigger) there's no "currently open document" to race against and no
739
858
  // de-dupe/staleness guard is needed.
740
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;
741
907
  this.navPaneWidth = 280;
742
908
  this.splitRatio = 0.5;
743
909
  this.resizing = false;
@@ -763,6 +929,12 @@ export class MdzipWorkspaceView {
763
929
  this.tooltipState = null;
764
930
  this.tooltipShowTimer = null;
765
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;
766
938
  this.cmEditor = null;
767
939
  this.readOnlyCompartment = new Compartment();
768
940
  this.lineNumbersCompartment = new Compartment();
@@ -774,6 +946,16 @@ export class MdzipWorkspaceView {
774
946
  // syncScrollFromPreview/syncScrollToPreview).
775
947
  this.lastSyncedEditorScrollTop = null;
776
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;
777
959
  this.markdownExtensions = [];
778
960
  this.entryRenderers = [];
779
961
  this.renderingService = new MdzipRenderingService();
@@ -797,6 +979,7 @@ export class MdzipWorkspaceView {
797
979
  this.controlPolicy = resolveMdzipControlPolicy(options.controls);
798
980
  this.navigationMode = options.navigationMode ?? 'editor';
799
981
  this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
982
+ this.progressiveTextRendering = options.progressiveTextRendering ?? false;
800
983
  this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
801
984
  this.contentDensity = options.contentDensity ?? 'comfortable';
802
985
  this.previewMaxWidth = options.previewMaxWidth;
@@ -872,6 +1055,17 @@ export class MdzipWorkspaceView {
872
1055
  this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
873
1056
  this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
874
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"]');
875
1069
  this.elNavMenu = q('[data-ref="nav-menu"]');
876
1070
  this.elNameDialog = q('[data-ref="name-dialog"]');
877
1071
  this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
@@ -883,6 +1077,7 @@ export class MdzipWorkspaceView {
883
1077
  this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
884
1078
  this.elReplaceInput = q('[data-ref="replace-input"]');
885
1079
  this.elTooltip = q('[data-ref="tooltip"]');
1080
+ this.elCopyToast = q('[data-ref="copy-toast"]');
886
1081
  this.elEmptyState = q('[data-ref="empty-state"]');
887
1082
  this.prepareTooltips();
888
1083
  this.attachEvents();
@@ -904,6 +1099,7 @@ export class MdzipWorkspaceView {
904
1099
  this.resetRenderingState();
905
1100
  this.cmEditor?.destroy();
906
1101
  this.cmEditor = null;
1102
+ this.workspace?.dispose();
907
1103
  this.workspace = null;
908
1104
  this.replaceAssetSession(null);
909
1105
  this.conversionDocumentGeneration += 1;
@@ -985,6 +1181,7 @@ export class MdzipWorkspaceView {
985
1181
  this.resetRenderingState();
986
1182
  this.cmEditor?.destroy();
987
1183
  this.cmEditor = null;
1184
+ this.workspace?.dispose();
988
1185
  this.workspace = null;
989
1186
  this.replaceAssetSession(null);
990
1187
  this.conversionDocumentGeneration += 1;
@@ -1178,6 +1375,12 @@ export class MdzipWorkspaceView {
1178
1375
  this.teardownEntryRenderer();
1179
1376
  // Release any whenRendered() waiters so their promises do not hang.
1180
1377
  this.flushRenderedWaiters();
1378
+ try {
1379
+ this.workspace?.dispose();
1380
+ }
1381
+ catch {
1382
+ // Ignore worker teardown errors
1383
+ }
1181
1384
  try {
1182
1385
  this.cmEditor?.destroy();
1183
1386
  }
@@ -1290,6 +1493,9 @@ export class MdzipWorkspaceView {
1290
1493
  this.options.imageInsertMode = options.imageInsertMode;
1291
1494
  this.options.imageInsertHandler = options.imageInsertHandler;
1292
1495
  }
1496
+ setImageEditOptions(options) {
1497
+ this.options.imageEditHandler = options.imageEditHandler;
1498
+ }
1293
1499
  applyDensityClasses() {
1294
1500
  this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
1295
1501
  this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
@@ -1328,6 +1534,26 @@ export class MdzipWorkspaceView {
1328
1534
  this.options.onFailed?.(error);
1329
1535
  }
1330
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;
1331
1557
  }
1332
1558
  updatePreview(snapshot, entryClaimed) {
1333
1559
  if (entryClaimed) {
@@ -1343,11 +1569,7 @@ export class MdzipWorkspaceView {
1343
1569
  return;
1344
1570
  }
1345
1571
  const memo = this.previewMemo;
1346
- if (memo
1347
- && memo.path === snapshot.currentPath
1348
- && memo.pathType === snapshot.currentPathType
1349
- && memo.text === snapshot.currentText
1350
- && memo.colorScheme === this.colorScheme) {
1572
+ if (this.previewMemoMatchesSnapshot(snapshot)) {
1351
1573
  // Nothing that feeds the preview changed; keep the existing DOM and any
1352
1574
  // mounted extension handles.
1353
1575
  return;
@@ -1388,6 +1610,10 @@ export class MdzipWorkspaceView {
1388
1610
  const abort = new AbortController();
1389
1611
  this.previewAbort = abort;
1390
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
+ }
1391
1617
  let result;
1392
1618
  try {
1393
1619
  result = this.renderingService.renderMarkdown(snapshot.currentText, context);
@@ -1459,28 +1685,86 @@ export class MdzipWorkspaceView {
1459
1685
  */
1460
1686
  mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
1461
1687
  this.elPreviewContent.innerHTML = html;
1462
- const session = this.assetSession;
1463
- 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
+ }
1464
1717
  const pending = [];
1465
- for (const image of Array.from(this.elPreviewContent.querySelectorAll('img'))) {
1466
- applyRawHtmlImageSizeAttributes(image);
1467
- const source = image.getAttribute('src');
1468
- // Leave external, protocol-relative, data, and fragment URLs untouched.
1469
- if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
1470
- const alignClass = rawHtmlImageAlignClass(image);
1471
- if (alignClass) {
1472
- image.classList.add(alignClass);
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;
1473
1729
  }
1474
- continue;
1730
+ image.removeAttribute('src');
1731
+ if (animateImageHydration) {
1732
+ image.classList.add('mdzip-image-loading');
1733
+ }
1734
+ pending.push({ image, source });
1475
1735
  }
1476
- // Drop the archive-relative src so the browser does not fetch the bad
1477
- // path. Wrap the image in a collapsed slot so the text stays compact and
1478
- // immediately readable; the slot eases open to the reserved height once
1479
- // the image resolves.
1480
- image.removeAttribute('src');
1481
- if (animateImageHydration) {
1482
- image.classList.add('mdzip-image-loading');
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();
1483
1765
  }
1766
+ };
1767
+ const createImageSlot = (image) => {
1484
1768
  const slot = document.createElement('span');
1485
1769
  slot.className = animateImageHydration
1486
1770
  ? 'mdzip-image-slot'
@@ -1494,26 +1778,9 @@ export class MdzipWorkspaceView {
1494
1778
  }
1495
1779
  image.parentNode?.insertBefore(slot, image);
1496
1780
  slot.appendChild(image);
1497
- pending.push({ image, slot, source });
1498
- }
1499
- this.mountPreviewExtensions(context, generation);
1500
- const codeBlockHandle = this.mountCodeBlockControls();
1501
- if (codeBlockHandle) {
1502
- this.previewHandles.push(codeBlockHandle);
1503
- }
1504
- this.firePreviewRendered(snapshot, generation);
1505
- if (!session || pending.length === 0) {
1506
- this.fireAssetsHydrated(snapshot, generation);
1507
- return;
1508
- }
1509
- let remaining = pending.length;
1510
- const settle = () => {
1511
- remaining -= 1;
1512
- if (remaining === 0) {
1513
- this.fireAssetsHydrated(snapshot, generation);
1514
- }
1781
+ return slot;
1515
1782
  };
1516
- for (const { image, slot, source } of pending) {
1783
+ const hydrate = (image, slot, source) => {
1517
1784
  void session.resolveImage(source, context.currentPath).then((resolved) => {
1518
1785
  if (generation !== this.previewGeneration || context.signal.aborted) {
1519
1786
  settle();
@@ -1545,6 +1812,294 @@ export class MdzipWorkspaceView {
1545
1812
  this.openImageSlot(slot);
1546
1813
  settle();
1547
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;
1548
2103
  }
1549
2104
  }
1550
2105
  /**
@@ -1607,13 +2162,20 @@ export class MdzipWorkspaceView {
1607
2162
  + 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
1608
2163
  + 'blob: and data:.'));
1609
2164
  }
1610
- 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) {
1611
2173
  for (const extension of this.markdownExtensions) {
1612
2174
  if (!extension.mount) {
1613
2175
  continue;
1614
2176
  }
1615
2177
  try {
1616
- const mounted = extension.mount(this.elPreviewContent, context);
2178
+ const mounted = extension.mount(root, context);
1617
2179
  if (isThenable(mounted)) {
1618
2180
  void Promise.resolve(mounted).then((handle) => {
1619
2181
  if (!handle) {
@@ -1653,12 +2215,15 @@ export class MdzipWorkspaceView {
1653
2215
  * extension) — every consumer gets this automatically, gated by policy
1654
2216
  * rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
1655
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.
1656
2221
  */
1657
- mountCodeBlockControls() {
2222
+ mountCodeBlockControls(root = this.elPreviewContent) {
1658
2223
  if (!this.controlPolicy.codeBlockTools) {
1659
2224
  return null;
1660
2225
  }
1661
- const codeEls = Array.from(this.elPreviewContent.querySelectorAll('pre > code'));
2226
+ const codeEls = Array.from(root.querySelectorAll('pre > code'));
1662
2227
  if (codeEls.length === 0) {
1663
2228
  return null;
1664
2229
  }
@@ -2001,6 +2566,8 @@ export class MdzipWorkspaceView {
2001
2566
  syntaxHighlighting(mdzipMarkdownHighlight),
2002
2567
  hardBreakMarkerHighlight,
2003
2568
  htmlTagMarkerHighlight,
2569
+ noSpellcheckHighlight,
2570
+ imageEditAffordanceField,
2004
2571
  EditorView.lineWrapping,
2005
2572
  dropCursor(),
2006
2573
  // Content is contenteditable, but browsers don't agree on a default
@@ -2056,6 +2623,23 @@ export class MdzipWorkspaceView {
2056
2623
  void self.handleEditorImageDrop(file, event.clientX, event.clientY);
2057
2624
  return true;
2058
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) });
2059
2643
  }
2060
2644
  }),
2061
2645
  ],
@@ -2066,6 +2650,10 @@ export class MdzipWorkspaceView {
2066
2650
  if (scroller) {
2067
2651
  scroller.addEventListener('scroll', () => self.syncScrollToPreview());
2068
2652
  }
2653
+ editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
2654
+ const { from, to } = event.detail;
2655
+ void self.openImageEditFlow(from, to);
2656
+ });
2069
2657
  return editor;
2070
2658
  }
2071
2659
  async ensureCmEditor(force = false) {
@@ -2081,6 +2669,23 @@ export class MdzipWorkspaceView {
2081
2669
  const snapshot = this.workspace?.snapshot() ?? null;
2082
2670
  this.elEmptyState.hidden = snapshot !== null;
2083
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
+ }
2084
2689
  if (!snapshot) {
2085
2690
  this.elDocumentStrip.hidden = true;
2086
2691
  this.elToolbar.hidden = true;
@@ -2265,15 +2870,26 @@ export class MdzipWorkspaceView {
2265
2870
  this.elImageInsertPositionSelect.value = 'inline';
2266
2871
  this.updateImageInsertOptionControls();
2267
2872
  }
2268
- this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
2269
- if (this.packFilesDialogState) {
2270
- const { request } = this.packFilesDialogState;
2271
- this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
2272
- .map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
2273
- .join('');
2274
- this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
2275
- this.elPackFilesModeDocument.checked = true;
2276
- this.elPackFilesModeProject.checked = false;
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;
2277
2893
  }
2278
2894
  this.elMetadataDialog.hidden = !this.metadataDialogOpen;
2279
2895
  if (this.contextMenuState) {
@@ -2367,10 +2983,11 @@ export class MdzipWorkspaceView {
2367
2983
  }
2368
2984
  });
2369
2985
  // Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
2370
- // both panes' text at once. Scope it to the rendered content instead when
2371
- // focus is inside the preview pane (see the mousedown handler below for
2372
- // how it gets there); the source editor keeps its own defaultKeymap
2373
- // binding since CodeMirror's content root sits outside this element.
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.
2374
2991
  doc.addEventListener('keydown', (e) => {
2375
2992
  if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
2376
2993
  return;
@@ -2379,7 +2996,7 @@ export class MdzipWorkspaceView {
2379
2996
  return;
2380
2997
  }
2381
2998
  e.preventDefault();
2382
- this.selectAllPreviewContent();
2999
+ void this.copyAllPreviewContent();
2383
3000
  });
2384
3001
  this.elNavBtn.addEventListener('click', () => {
2385
3002
  if (!this.controlPolicy.navigation) {
@@ -2650,6 +3267,28 @@ export class MdzipWorkspaceView {
2650
3267
  this.elPackFilesConfirmBtn.addEventListener('click', () => {
2651
3268
  this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
2652
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
+ });
2653
3292
  this.elNavMenu.addEventListener('click', (e) => {
2654
3293
  e.stopPropagation();
2655
3294
  const item = e.target.closest('[data-menu-action]');
@@ -2915,6 +3554,18 @@ export class MdzipWorkspaceView {
2915
3554
  this.tooltipHideTimer = null;
2916
3555
  }, 40);
2917
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
+ }
2918
3569
  async save() {
2919
3570
  try {
2920
3571
  const workspace = this.workspace;
@@ -2969,15 +3620,35 @@ export class MdzipWorkspaceView {
2969
3620
  }
2970
3621
  renderMetadata(snapshot) {
2971
3622
  const manifest = snapshot.content.manifest;
3623
+ const isMdz = snapshot.sourceFormat === 'mdz';
2972
3624
  const fields = [
2973
3625
  ['Filename', snapshot.fileName],
2974
- ['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)],
2975
3634
  ['Document title', snapshot.displayTitle],
2976
3635
  ['First heading', snapshot.headingFallback ?? 'Not found'],
2977
3636
  ['Created', formatMetadataValue(manifest?.created)],
2978
3637
  ['Modified', formatMetadataValue(manifest?.modified)],
2979
- ['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']
2980
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
+ }
2981
3652
  this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
2982
3653
  return this.createMetadataRow(label, value);
2983
3654
  }));
@@ -3293,6 +3964,61 @@ export class MdzipWorkspaceView {
3293
3964
  this.cmEditor.focus();
3294
3965
  }
3295
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
+ }
3296
4022
  async resolveImageInsertDecision(bytes, mimeType, options) {
3297
4023
  const size = sniffImageSize(bytes, mimeType);
3298
4024
  const request = {
@@ -3589,7 +4315,7 @@ export class MdzipWorkspaceView {
3589
4315
  }
3590
4316
  }
3591
4317
  // Items for the rendered-preview selection menu. Taking over `contextmenu`
3592
- // 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
3593
4319
  // the only thing that was otherwise offering Copy on a right-click — so
3594
4320
  // Copy has to be reinstated explicitly whenever there's a selection.
3595
4321
  previewMenuItems() {
@@ -3603,11 +4329,16 @@ export class MdzipWorkspaceView {
3603
4329
  items.push(null);
3604
4330
  }
3605
4331
  items.push({
3606
- action: 'preview-select-all',
3607
- label: 'Select All',
3608
- icon: MENU_SELECT_ALL_ICON_HTML,
4332
+ action: 'preview-copy-all',
4333
+ label: 'Copy All',
4334
+ icon: MENU_COPY_ICON_HTML,
3609
4335
  shortcut: this.editorShortcut('A')
3610
4336
  });
4337
+ items.push({
4338
+ action: 'preview-copy-all-images',
4339
+ label: 'Copy All with Images',
4340
+ icon: MENU_COPY_ICON_HTML
4341
+ });
3611
4342
  return items;
3612
4343
  }
3613
4344
  handlePreviewMenuAction(action) {
@@ -3618,41 +4349,448 @@ export class MdzipWorkspaceView {
3618
4349
  return;
3619
4350
  }
3620
4351
  switch (action) {
3621
- case 'preview-select-all':
3622
- this.selectAllPreviewContent();
4352
+ case 'preview-copy-all':
4353
+ void this.copyAllPreviewContent();
4354
+ break;
4355
+ case 'preview-copy-all-images':
4356
+ void this.copyAllWithImagesPreviewContent();
3623
4357
  break;
3624
4358
  case 'preview-copy':
3625
- 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));
3626
4362
  break;
3627
4363
  }
3628
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). */
3629
4436
  async copyPreviewSelection(text) {
3630
4437
  if (!text) {
3631
- return;
4438
+ return null;
3632
4439
  }
3633
4440
  try {
3634
- 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' };
3635
4443
  }
3636
4444
  catch (error) {
3637
4445
  this.options.onFailed?.(error);
4446
+ return { error: error instanceof Error ? error.message : String(error) };
3638
4447
  }
3639
4448
  }
3640
- // Selects the full rendered content of the preview pane, scoped to that
3641
- // pane rather than the whole page (native Ctrl+A / right-click Select All
3642
- // would otherwise grab the source editor's text too in split layout).
3643
- selectAllPreviewContent() {
3644
- const doc = this.elPreviewContent.ownerDocument;
3645
- const selection = doc.defaultView?.getSelection();
3646
- if (!selection) {
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) {
4452
+ return;
4453
+ }
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 ?? ''));
3647
4518
  return;
3648
4519
  }
3649
- // Focus before selecting: focusing the pane after building the range
3650
- // collapses the selection right back out.
3651
- this.elPreviewPane.focus({ preventScroll: true });
3652
- const range = doc.createRange();
3653
- range.selectNodeContents(this.elPreviewContent);
3654
- selection.removeAllRanges();
3655
- selection.addRange(range);
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
+ }
3656
4794
  }
3657
4795
  // Inserts a fenced code block, carrying the chosen language as the fence info
3658
4796
  // string. An empty language produces a plain ```` ``` ```` block.
@@ -4658,8 +5796,29 @@ export class MdzipWorkspaceView {
4658
5796
  if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
4659
5797
  return;
4660
5798
  }
4661
- this.syncing = true;
4662
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;
4663
5822
  const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
4664
5823
  const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
4665
5824
  const target = scrollRatio * previewHeight;
@@ -4667,6 +5826,84 @@ export class MdzipWorkspaceView {
4667
5826
  this.elPreviewPane.scrollTop = target;
4668
5827
  this.syncing = false;
4669
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
+ }
4670
5907
  }
4671
5908
  function canShowSourceLayout(snapshot) {
4672
5909
  return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
@@ -4791,8 +6028,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
4791
6028
  : after.startsWith('\n') ? '\n' : '\n\n';
4792
6029
  return `${prefix}${html}${suffix}`;
4793
6030
  }
4794
- function escapeMarkdownImageAlt(value) {
4795
- 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]}`;
4796
6049
  }
4797
6050
  function formatMetadataValue(value) {
4798
6051
  if (typeof value === 'string' && value.trim()) {
@@ -5052,6 +6305,30 @@ const SHELL_HTML = `
5052
6305
  </div>
5053
6306
  </div>
5054
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
+
5055
6332
  <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
5056
6333
  role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
5057
6334
  <div class="title-dialog metadata-dialog">
@@ -5096,6 +6373,8 @@ const SHELL_HTML = `
5096
6373
 
5097
6374
  <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
5098
6375
 
6376
+ <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
6377
+
5099
6378
  <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
5100
6379
  </section>
5101
6380
  `;