@mdzip/editor 1.4.0 → 1.4.5

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 (50) hide show
  1. package/README.md +35 -0
  2. package/dist/asset-cache.d.ts +2 -0
  3. package/dist/asset-cache.d.ts.map +1 -1
  4. package/dist/asset-cache.js +12 -0
  5. package/dist/asset-cache.js.map +1 -1
  6. package/dist/front-matter-extension.d.ts +45 -0
  7. package/dist/front-matter-extension.d.ts.map +1 -0
  8. package/dist/front-matter-extension.js +114 -0
  9. package/dist/front-matter-extension.js.map +1 -0
  10. package/dist/front-matter.d.ts +18 -0
  11. package/dist/front-matter.d.ts.map +1 -0
  12. package/dist/front-matter.js +48 -0
  13. package/dist/front-matter.js.map +1 -0
  14. package/dist/highlight-core.d.ts +3 -0
  15. package/dist/highlight-core.d.ts.map +1 -0
  16. package/dist/highlight-core.js +53 -0
  17. package/dist/highlight-core.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/library-info.d.ts +1 -1
  23. package/dist/library-info.js +1 -1
  24. package/dist/mermaid.d.ts.map +1 -1
  25. package/dist/mermaid.js +60 -41
  26. package/dist/mermaid.js.map +1 -1
  27. package/dist/metadata.d.ts +1 -1
  28. package/dist/metadata.d.ts.map +1 -1
  29. package/dist/metadata.js +12 -2
  30. package/dist/metadata.js.map +1 -1
  31. package/dist/preview.d.ts +1 -0
  32. package/dist/preview.d.ts.map +1 -1
  33. package/dist/preview.js +1 -0
  34. package/dist/preview.js.map +1 -1
  35. package/dist/rendering.d.ts +94 -1
  36. package/dist/rendering.d.ts.map +1 -1
  37. package/dist/rendering.js +173 -5
  38. package/dist/rendering.js.map +1 -1
  39. package/dist/view-css.d.ts.map +1 -1
  40. package/dist/view-css.js +67 -0
  41. package/dist/view-css.js.map +1 -1
  42. package/dist/view.d.ts +151 -10
  43. package/dist/view.d.ts.map +1 -1
  44. package/dist/view.js +745 -78
  45. package/dist/view.js.map +1 -1
  46. package/dist/workspace-view.d.ts +1 -0
  47. package/dist/workspace-view.d.ts.map +1 -1
  48. package/dist/workspace-view.js +14 -3
  49. package/dist/workspace-view.js.map +1 -1
  50. package/package.json +3 -1
package/dist/view.js CHANGED
@@ -12,9 +12,10 @@ import { MdzipAssetSession, mdzipArchiveSourceId, sniffImageSize } from './asset
12
12
  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
- import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
15
+ import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, isMdzipWorkspaceRelativeLink, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
16
16
  import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
17
- import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks } from './rendering.js';
17
+ import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, collectMdzipHeadingIds, MDZIP_HEADING_ID_PREFIX, tokenEmbedsImage, tokenIsHeading } from './rendering.js';
18
+ import { mdzipFrontMatterExtension } from './front-matter-extension.js';
18
19
  import { WORKSPACE_CSS } from './view-css.js';
19
20
  const STYLE_ATTR = 'data-mdzip-ws-styles';
20
21
  const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
@@ -838,6 +839,8 @@ function rawHtmlImageAlignClass(image) {
838
839
  return null;
839
840
  }
840
841
  export class MdzipWorkspaceView {
842
+ static { this.SCROLL_SYNC_SETTLE_MS = 250; }
843
+ static { this.SCROLL_SYNC_GESTURE_WINDOW_MS = 500; }
841
844
  constructor(container, options = {}) {
842
845
  this.workspace = null;
843
846
  this.assetSession = null;
@@ -859,12 +862,19 @@ export class MdzipWorkspaceView {
859
862
  this.packFilesDialogState = null;
860
863
  // Tracks an in-progress progressive (chunked) preview render so Copy All
861
864
  // 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
865
+ // `records` are mounted so far; null once everything's mounted (or when
863
866
  // progressive rendering isn't active at all) — that's Copy All's signal
864
867
  // to skip the dialog and copy instantly. `sentinelHandle` is the
865
868
  // scroll-driven continuation's IntersectionObserver, if one is currently
866
869
  // armed; Copy All tears it down before manually draining so the two don't
867
870
  // race and double-mount the same chunk.
871
+ //
872
+ // Each record carries its own mounted DOM root and extension/code-block
873
+ // handles (rather than those living only in the flat `previewHandles`
874
+ // array below) so that a same-document text edit can reconcile against the
875
+ // *previous* render's records: chunks whose `chunkSourceKey` didn't change
876
+ // keep their existing `root`/`handles` untouched — see
877
+ // `applyChunkReconciliation`.
868
878
  this.chunkedRenderState = null;
869
879
  // Per-generation memo of renderChunk's raw HTML output, keyed by chunk
870
880
  // index, shared between the DOM-mount path (renderAndMountChunkBatch) and
@@ -946,6 +956,56 @@ export class MdzipWorkspaceView {
946
956
  // syncScrollFromPreview/syncScrollToPreview).
947
957
  this.lastSyncedEditorScrollTop = null;
948
958
  this.lastSyncedPreviewScrollTop = null;
959
+ // Timestamp of the most recent updatePreview() call (edit-driven or
960
+ // otherwise). A content change that alters a mounted element's rendered
961
+ // height (a chunk reconcile re-rendering a mermaid diagram, an image
962
+ // finishing decode, CodeMirror re-measuring line heights after the edit
963
+ // that triggered it) can cause a 'scroll' event with no explicit write
964
+ // behind it — the browser's own scroll anchoring, or CodeMirror's internal
965
+ // viewport/anchor recalculation, adjusting scrollTop on its own to keep
966
+ // content visually pinned. That's indistinguishable from a real user
967
+ // scroll to the listeners below, so within a brief window after a content
968
+ // change we treat scroll events as noise from settling layout rather than
969
+ // sync-worthy user intent — otherwise this class's own ratio-based writes
970
+ // (raw pixel values that don't correspond to any document position
971
+ // CodeMirror has actually measured) can themselves destabilize
972
+ // CodeMirror's virtualized viewport measurement mid-settle.
973
+ this.lastContentChangeTime = 0;
974
+ // Timestamp of the most recent genuine user-input gesture (wheel, touch,
975
+ // mousedown — including a scrollbar-thumb drag, which lands on the same
976
+ // element — or a key press) on each pane. Content-change-adjacent 'scroll'
977
+ // events (see lastContentChangeTime above) turned out to be one of several
978
+ // distinct ways a non-user-driven scroll can fire: native CSS scroll
979
+ // anchoring reacting to an unrelated element's height changing — a chunk
980
+ // reconcile, an image decode, or (confirmed live) an image/mermaid reveal
981
+ // transition finishing several seconds after the edit that triggered it —
982
+ // can smoothly drive scrollTop, frame by frame, all the way to a pane's
983
+ // absolute end, entirely decoupled from any edit and well outside any
984
+ // settle window. Rather than continuing to special-case each newly
985
+ // discovered non-user cause after the fact, sync is now gated on positive
986
+ // evidence of real user intent: a 'scroll' event is only treated as
987
+ // sync-worthy if a genuine input gesture on that same pane happened very
988
+ // recently. Everything else — anchoring, our own reconcile restores,
989
+ // CodeMirror's internal viewport correction — is left alone by definition.
990
+ this.lastEditorGestureTime = 0;
991
+ this.lastPreviewGestureTime = 0;
992
+ // Every preview re-render — whether reconciled or a full cold-start reset
993
+ // (mountChunkedPreview unconditionally does `elPreviewContent.
994
+ // replaceChildren()`) — has a real window where the preview is shorter
995
+ // than before (old DOM torn down, replacement mounted asynchronously: a
996
+ // chunk reconcile, a full reset, mermaid's async completion). If the
997
+ // current scroll position no longer fits that transient shrink, the
998
+ // browser clamps scrollTop to what does fit and never un-clamps once the
999
+ // replacement's DOM restores the true height. Captured once per
1000
+ // updatePreview() call (before anything moves), restored in
1001
+ // firePreviewRendered — the one point every rendering path (reconcile,
1002
+ // cold-start, progressive, eager, mermaid-triggered or not) already calls
1003
+ // once its content is back in the DOM.
1004
+ this.pendingPreviewScrollRestore = null;
1005
+ // A #fragment from a link into another document (`other.md#heading`), held
1006
+ // until that document's preview has mounted so there is a heading to scroll
1007
+ // to. Consumed by firePreviewRendered.
1008
+ this.pendingAnchorFragment = null;
949
1009
  // Guards syncScrollToPreviewBottom against overlapping drains: set to the
950
1010
  // chunkedRenderState generation currently being force-drained, null when
951
1011
  // none is in flight. A second bottom-edge sync that arrives mid-drain
@@ -957,6 +1017,7 @@ export class MdzipWorkspaceView {
957
1017
  // debounce) — see syncScrollToPreviewBottom.
958
1018
  this.scrollCatchUpState = null;
959
1019
  this.markdownExtensions = [];
1020
+ this.frontMatterOptions = {};
960
1021
  this.entryRenderers = [];
961
1022
  this.renderingService = new MdzipRenderingService();
962
1023
  // Preview render memo: the preview pipeline only re-runs when one of these
@@ -991,8 +1052,9 @@ export class MdzipWorkspaceView {
991
1052
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
992
1053
  this.markdownRenderer = options.markdownRenderer;
993
1054
  this.markdownExtensions = options.markdownExtensions ?? [];
1055
+ this.frontMatterOptions = options.frontMatter ?? {};
994
1056
  this.entryRenderers = options.entryRenderers ?? [];
995
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1057
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
996
1058
  injectStyles(container.ownerDocument);
997
1059
  container.replaceChildren();
998
1060
  container.innerHTML = SHELL_HTML;
@@ -1407,15 +1469,46 @@ export class MdzipWorkspaceView {
1407
1469
  if (options.markdownExtensions) {
1408
1470
  this.markdownExtensions = options.markdownExtensions;
1409
1471
  }
1472
+ if (options.frontMatter) {
1473
+ this.frontMatterOptions = options.frontMatter;
1474
+ }
1410
1475
  if (options.entryRenderers) {
1411
1476
  this.entryRenderers = options.entryRenderers;
1412
1477
  }
1413
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1478
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
1414
1479
  this.resetPreviewState();
1415
1480
  this.teardownEntryRenderer();
1416
1481
  this.entryMatchMissKey = null;
1417
1482
  this.render();
1418
1483
  }
1484
+ /**
1485
+ * The extensions actually handed to `MdzipRenderingService`: the front
1486
+ * matter extension first (always on — see `frontMatterOptions`), then
1487
+ * whatever extensions the host passed via `markdownExtensions`. A fresh
1488
+ * front matter extension instance is created each time this runs (view
1489
+ * construction, `setRenderingOptions`), which is fine — its per-render
1490
+ * state lives in a `WeakMap` keyed by the render context, not on the
1491
+ * extension instance across renders.
1492
+ */
1493
+ pipelineMarkdownExtensions() {
1494
+ return [mdzipFrontMatterExtension(this.frontMatterOptions), ...this.markdownExtensions];
1495
+ }
1496
+ /**
1497
+ * Chunking options combining every registered extension's
1498
+ * {@link MdzipMarkdownRenderExtension.shouldIsolateChunk} — see that doc
1499
+ * comment for why a token an extension owns needs its own chunk regardless
1500
+ * of the size budget — with the same treatment built in for any token that
1501
+ * embeds an image ({@link tokenEmbedsImage}'s doc comment), plus a forced
1502
+ * chunk boundary at every heading ({@link tokenIsHeading}'s doc comment)
1503
+ * so unrelated sections never share a chunk.
1504
+ */
1505
+ chunkOptions() {
1506
+ const extensions = this.pipelineMarkdownExtensions();
1507
+ return {
1508
+ shouldIsolate: (token) => tokenEmbedsImage(token) || extensions.some((ext) => ext.shouldIsolateChunk?.(token)),
1509
+ shouldStartChunk: tokenIsHeading
1510
+ };
1511
+ }
1419
1512
  /**
1420
1513
  * Replaces the view control policy without recreating the workspace or
1421
1514
  * CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
@@ -1534,6 +1627,19 @@ export class MdzipWorkspaceView {
1534
1627
  this.options.onFailed?.(error);
1535
1628
  }
1536
1629
  }
1630
+ // Per-chunk handles live on their own records (see MdzipChunkRecord), not
1631
+ // in the flat array above — a full reset must still destroy every one of
1632
+ // them, the same as it always destroyed everything in previewHandles.
1633
+ for (const record of this.chunkedRenderState?.records ?? []) {
1634
+ for (const handle of record.handles) {
1635
+ try {
1636
+ handle.destroy();
1637
+ }
1638
+ catch (error) {
1639
+ this.options.onFailed?.(error);
1640
+ }
1641
+ }
1642
+ }
1537
1643
  // A new render generation invalidates any in-progress chunk draining —
1538
1644
  // Copy All's own abort check unwinds it and hides the dialog. It also
1539
1645
  // invalidates a "ready to copy" or "done" dialog left over from a
@@ -1556,6 +1662,10 @@ export class MdzipWorkspaceView {
1556
1662
  && memo.colorScheme === this.colorScheme;
1557
1663
  }
1558
1664
  updatePreview(snapshot, entryClaimed) {
1665
+ this.lastContentChangeTime = performance.now();
1666
+ // Captured before anything below can move it — see
1667
+ // `pendingPreviewScrollRestore`'s doc comment.
1668
+ const previewScrollTopBeforeUpdate = this.elPreviewPane.scrollTop;
1559
1669
  if (entryClaimed) {
1560
1670
  // The entry renderer owns the pane stack; release preview resources so
1561
1671
  // a later fallback re-renders from scratch.
@@ -1575,10 +1685,33 @@ export class MdzipWorkspaceView {
1575
1685
  return;
1576
1686
  }
1577
1687
  const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
1688
+ // A same-document, text-only edit with an existing chunked structure can
1689
+ // be reconciled against (only the chunks whose source actually changed
1690
+ // get touched) instead of torn down and rebuilt from scratch. Every
1691
+ // other case — first render, a path/pathType/colorScheme change, a
1692
+ // non-default renderer, a prior explicit reset that already cleared
1693
+ // chunkedRenderState, or an edit-triggered batch mount still in flight
1694
+ // for the current generation (`mounting` — see its doc comment: its
1695
+ // `cursor` doesn't yet reflect where that mount actually is, so
1696
+ // reconciling against it risks orphaned duplicate DOM) — falls through
1697
+ // to today's full reset + cold start, unchanged.
1698
+ const reconcileEligible = !!memo
1699
+ && memo.path === snapshot.currentPath
1700
+ && memo.pathType === snapshot.currentPathType
1701
+ && memo.colorScheme === this.colorScheme
1702
+ && snapshot.currentPathType === 'markdown'
1703
+ && this.renderingService.supportsChunking
1704
+ && !!this.chunkedRenderState
1705
+ && this.chunkedRenderState.generation === this.previewGeneration
1706
+ && !this.chunkedRenderState.mounting;
1707
+ const priorChunkedState = reconcileEligible ? this.chunkedRenderState : null;
1578
1708
  this.previewAbort?.abort();
1579
1709
  this.previewAbort = null;
1580
- this.destroyPreviewHandles();
1710
+ if (!priorChunkedState) {
1711
+ this.destroyPreviewHandles();
1712
+ }
1581
1713
  const generation = ++this.previewGeneration;
1714
+ this.pendingPreviewScrollRestore = { scrollTop: previewScrollTopBeforeUpdate, generation };
1582
1715
  this.previewHydrated = false;
1583
1716
  this.previewMemo = {
1584
1717
  path: snapshot.currentPath,
@@ -1610,8 +1743,13 @@ export class MdzipWorkspaceView {
1610
1743
  const abort = new AbortController();
1611
1744
  this.previewAbort = abort;
1612
1745
  const context = this.createMarkdownContext(snapshot, abort.signal);
1613
- if (this.progressiveTextRendering && this.renderingService.supportsChunking) {
1614
- this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1746
+ if (this.renderingService.supportsChunking) {
1747
+ if (priorChunkedState) {
1748
+ this.reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorChunkedState);
1749
+ }
1750
+ else {
1751
+ this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1752
+ }
1615
1753
  return;
1616
1754
  }
1617
1755
  let result;
@@ -1689,7 +1827,7 @@ export class MdzipWorkspaceView {
1689
1827
  // controls mount, same relative order as always — see
1690
1828
  // collectPendingImages for why it's split from the (expensive) slot
1691
1829
  // creation that follows.
1692
- const pending = this.collectPendingImages([this.elPreviewContent], animateImageHydration);
1830
+ const pending = this.collectPendingImages([this.elPreviewContent], context, generation, animateImageHydration);
1693
1831
  this.mountPreviewExtensions(context, generation);
1694
1832
  const codeBlockHandle = this.mountCodeBlockControls();
1695
1833
  if (codeBlockHandle) {
@@ -1709,8 +1847,19 @@ export class MdzipWorkspaceView {
1709
1847
  * class, if any — they never get a slot). Returns `[]` without touching
1710
1848
  * anything when there's no asset session to resolve archive images
1711
1849
  * against, matching `mountPreviewHtml`'s no-op image handling.
1850
+ *
1851
+ * An image this session has *already* resolved once (e.g. it was already
1852
+ * on screen before an edit elsewhere caused its chunk to remount) is
1853
+ * applied immediately here via `resolveKnownImage` — no slot, no loading
1854
+ * class, no `hydrateImages`/`IntersectionObserver` round trip — instead of
1855
+ * being handed to the async placeholder-then-swap path meant for an image
1856
+ * that's genuinely being resolved for the first time. Skipping that path
1857
+ * for a re-mount of an already-known image is what actually avoids the
1858
+ * visible flash; still going through it (even instantly) briefly blanks
1859
+ * the image, since a fresh `<img>` element never carries over its
1860
+ * predecessor's already-resolved `src`.
1712
1861
  */
1713
- collectPendingImages(roots, animateImageHydration) {
1862
+ collectPendingImages(roots, context, generation, animateImageHydration) {
1714
1863
  if (!this.assetSession) {
1715
1864
  return [];
1716
1865
  }
@@ -1727,6 +1876,22 @@ export class MdzipWorkspaceView {
1727
1876
  }
1728
1877
  continue;
1729
1878
  }
1879
+ const known = this.assetSession.resolveKnownImage(source, context.currentPath);
1880
+ if (known) {
1881
+ if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
1882
+ image.setAttribute('width', String(known.width));
1883
+ image.setAttribute('height', String(known.height));
1884
+ }
1885
+ // No slot wrapper on this path — same direct-on-image alignment as
1886
+ // the external-URL branch above, not the slot's own align classes.
1887
+ const alignClass = rawHtmlImageAlignClass(image);
1888
+ if (alignClass) {
1889
+ image.classList.add(alignClass);
1890
+ }
1891
+ this.attachImageLoadHandlers(image, source, known.url, context, generation);
1892
+ image.setAttribute('src', known.url);
1893
+ continue;
1894
+ }
1730
1895
  image.removeAttribute('src');
1731
1896
  if (animateImageHydration) {
1732
1897
  image.classList.add('mdzip-image-loading');
@@ -1896,6 +2061,201 @@ export class MdzipWorkspaceView {
1896
2061
  }
1897
2062
  });
1898
2063
  }
2064
+ /**
2065
+ * Same-document, text-only-edit counterpart to {@link renderChunkedPreview}:
2066
+ * tokenizes the new text, then reconciles against the *previous* render's
2067
+ * records (`priorState`) instead of tearing everything down. Only reachable
2068
+ * when `updatePreview` determined the prior chunked structure is still
2069
+ * reuse-eligible (same path/pathType/colorScheme); the caller already
2070
+ * checked that.
2071
+ */
2072
+ reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorState) {
2073
+ let tokens;
2074
+ try {
2075
+ tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
2076
+ }
2077
+ catch (error) {
2078
+ this.options.onFailed?.(error);
2079
+ // Can't reconcile without tokens — fall back to a hard reset.
2080
+ this.destroyPreviewHandles();
2081
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
2082
+ this.firePreviewRendered(snapshot, generation);
2083
+ this.fireAssetsHydrated(snapshot, generation);
2084
+ return;
2085
+ }
2086
+ const proceed = (resolvedTokens) => {
2087
+ if (generation !== this.previewGeneration || context.signal.aborted)
2088
+ return;
2089
+ this.applyChunkReconciliation(resolvedTokens, snapshot, context, generation, animateImageHydration, priorState);
2090
+ };
2091
+ if (Array.isArray(tokens)) {
2092
+ proceed(tokens);
2093
+ return;
2094
+ }
2095
+ void tokens.then(proceed).catch((error) => {
2096
+ if (generation !== this.previewGeneration || context.signal.aborted)
2097
+ return;
2098
+ if (error?.name !== 'AbortError') {
2099
+ this.options.onFailed?.(error);
2100
+ }
2101
+ });
2102
+ }
2103
+ /** Destroys one chunk record's mounted DOM and handles (but not the record object itself). */
2104
+ teardownChunkRecord(record) {
2105
+ for (const handle of record.handles) {
2106
+ try {
2107
+ handle.destroy();
2108
+ }
2109
+ catch (error) {
2110
+ this.options.onFailed?.(error);
2111
+ }
2112
+ }
2113
+ record.root?.remove();
2114
+ }
2115
+ /**
2116
+ * Diffs the new tokenize pass's chunks against `priorState.records` by
2117
+ * source-text identity (`chunkSourceKey`) and reconciles: chunks in the
2118
+ * matched leading/trailing run keep their existing DOM and handles
2119
+ * untouched (never re-rendered, never re-mounted — see
2120
+ * `chunkSourceKey`'s doc comment for why that matters for extensions like
2121
+ * mermaid); only the "middle" range that actually changed is torn down and
2122
+ * remounted. Uses prefix/suffix matching rather than a general LCS — a
2123
+ * text editor's edits are localized, so this is both simpler and correct
2124
+ * for the real-world case.
2125
+ */
2126
+ applyChunkReconciliation(tokens, snapshot, context, generation, animateImageHydration, priorState) {
2127
+ // Tear down any live sentinel from the prior state before touching DOM —
2128
+ // same reasoning as drainRemainingChunks: stops it from racing this
2129
+ // reconciliation and double-mounting a chunk.
2130
+ priorState.sentinelHandle?.destroy();
2131
+ this.previewHandles = this.previewHandles.filter((h) => h !== priorState.sentinelHandle);
2132
+ const newChunks = groupTokensIntoChunks(tokens, this.chunkOptions());
2133
+ const oldRecords = priorState.records;
2134
+ const oldCursor = priorState.cursor;
2135
+ if (newChunks.length === 0) {
2136
+ // Whole document collapsed to nothing renderable — nothing to reuse.
2137
+ for (const record of oldRecords) {
2138
+ this.teardownChunkRecord(record);
2139
+ }
2140
+ this.elPreviewContent.replaceChildren();
2141
+ this.chunkedRenderState = null;
2142
+ this.firePreviewRendered(snapshot, generation);
2143
+ this.fireAssetsHydrated(snapshot, generation);
2144
+ return;
2145
+ }
2146
+ const oldKeys = oldRecords.map((r) => r.key);
2147
+ const newKeys = newChunks.map((c) => chunkSourceKey(c));
2148
+ const maxMatch = Math.min(oldKeys.length, newKeys.length);
2149
+ let prefixLen = 0;
2150
+ while (prefixLen < maxMatch && oldKeys[prefixLen] === newKeys[prefixLen])
2151
+ prefixLen += 1;
2152
+ const maxSuffix = maxMatch - prefixLen;
2153
+ let suffixLen = 0;
2154
+ while (suffixLen < maxSuffix
2155
+ && oldKeys[oldKeys.length - 1 - suffixLen] === newKeys[newKeys.length - 1 - suffixLen])
2156
+ suffixLen += 1;
2157
+ const oldMiddleStart = prefixLen;
2158
+ const oldMiddleEnd = oldRecords.length - suffixLen;
2159
+ const newMiddleStart = prefixLen;
2160
+ const newMiddleEnd = newChunks.length - suffixLen;
2161
+ const newRecords = [];
2162
+ for (let i = 0; i < prefixLen; i += 1) {
2163
+ newRecords.push({ ...oldRecords[i], key: newKeys[i], tokens: newChunks[i] });
2164
+ }
2165
+ for (let i = newMiddleStart; i < newMiddleEnd; i += 1) {
2166
+ newRecords.push({ key: newKeys[i], tokens: newChunks[i], root: null, html: null, handles: [] });
2167
+ }
2168
+ for (let i = 0; i < suffixLen; i += 1) {
2169
+ const oi = oldRecords.length - suffixLen + i;
2170
+ const ni = newChunks.length - suffixLen + i;
2171
+ newRecords.push({ ...oldRecords[oi], key: newKeys[ni], tokens: newChunks[ni] });
2172
+ }
2173
+ // Destroy DOM+handles for old-middle records that were actually mounted
2174
+ // (bounded by the old cursor — everything from there on was still
2175
+ // waiting in the lazy-load tail, never mounted, nothing to destroy).
2176
+ const oldMountedMiddleEnd = Math.min(oldCursor, oldMiddleEnd);
2177
+ for (let i = oldMiddleStart; i < oldMountedMiddleEnd; i += 1) {
2178
+ this.teardownChunkRecord(oldRecords[i]);
2179
+ }
2180
+ const oldMountedMiddleCount = Math.max(0, oldMountedMiddleEnd - oldMiddleStart);
2181
+ // Reference node for splicing in the new middle's DOM: the first
2182
+ // surviving *mounted* suffix record (mounted suffix records are
2183
+ // contiguous from newMiddleEnd by construction), else null (append).
2184
+ let insertBeforeNode = null;
2185
+ for (let i = newMiddleEnd; i < newRecords.length; i += 1) {
2186
+ if (newRecords[i].root) {
2187
+ insertBeforeNode = newRecords[i].root;
2188
+ break;
2189
+ }
2190
+ }
2191
+ this.chunkedRenderState = {
2192
+ records: newRecords,
2193
+ cursor: oldCursor,
2194
+ context,
2195
+ generation,
2196
+ animateImageHydration,
2197
+ sentinelHandle: null,
2198
+ mounting: oldMountedMiddleCount > 0
2199
+ };
2200
+ if (oldMountedMiddleCount === 0) {
2201
+ // The edit is entirely beyond what was mounted (progressive mode,
2202
+ // still in the lazy-load tail) — nothing was destroyed, nothing needs
2203
+ // mounting now. Cursor is unaffected: every index below it is an
2204
+ // untouched prefix survivor, valid at the same index in newRecords. The
2205
+ // old sentinel was already torn down above, so a fresh one has to be
2206
+ // armed to keep the lazy-load tail continuing.
2207
+ if (oldCursor < newRecords.length) {
2208
+ this.armChunkSentinel(newRecords, oldCursor, context, generation, animateImageHydration);
2209
+ }
2210
+ this.firePreviewRendered(snapshot, generation);
2211
+ this.fireAssetsHydrated(snapshot, generation);
2212
+ return;
2213
+ }
2214
+ void this.mountReconciledMiddle(newRecords, newMiddleStart, newMiddleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd);
2215
+ }
2216
+ /**
2217
+ * Mounts a reconciliation's replacement "middle" range in full (uncapped —
2218
+ * a single edit only ever touches 1-2 chunks in practice; see the plan's
2219
+ * disclosed v1 risk for the pathological large-paste case), then carries
2220
+ * forward however much of the surviving suffix was already mounted before
2221
+ * reconciling, and re-arms the sentinel for whatever's still left.
2222
+ */
2223
+ async mountReconciledMiddle(records, middleStart, middleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd) {
2224
+ let cursor = middleStart;
2225
+ let mountedRoots = [];
2226
+ while (cursor < middleEnd) {
2227
+ const batch = await this.renderAndMountChunkBatch(records, cursor, context, generation, insertBeforeNode, middleEnd);
2228
+ if (generation !== this.previewGeneration || context.signal.aborted)
2229
+ return;
2230
+ if (batch.cursor === cursor)
2231
+ break; // Aborted/superseded mid-loop with no progress.
2232
+ cursor = batch.cursor;
2233
+ mountedRoots = mountedRoots.concat(batch.mountedRoots);
2234
+ }
2235
+ const carriedSuffixMounted = Math.max(0, oldCursor - oldMiddleEnd);
2236
+ const finalCursor = Math.min(records.length, Math.max(cursor, middleEnd) + carriedSuffixMounted);
2237
+ // See armChunkSentinel's mountNext for why this is recorded before the
2238
+ // staleness check: finalCursor already correctly folds in the carried-
2239
+ // forward suffix (chunks beyond the reconciled middle that were already
2240
+ // mounted), so discarding it here just because a newer edit has since
2241
+ // landed would regress chunkedRenderState.cursor back down — causing the
2242
+ // next reconcile to re-arm a sentinel from that stale, lower cursor and
2243
+ // re-mount (duplicate) chunks that are already correctly in the DOM.
2244
+ this.recordChunkProgress(generation, records, finalCursor);
2245
+ if (generation !== this.previewGeneration || context.signal.aborted)
2246
+ return;
2247
+ if (this.chunkedRenderState?.generation === generation) {
2248
+ this.chunkedRenderState.mounting = false;
2249
+ }
2250
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2251
+ this.hydrateImages(pending, context, generation, animateImageHydration, () => {
2252
+ this.fireAssetsHydrated(snapshot, generation);
2253
+ });
2254
+ this.firePreviewRendered(snapshot, generation);
2255
+ if (this.chunkedRenderState?.generation === generation && finalCursor < records.length) {
2256
+ this.armChunkSentinel(records, finalCursor, context, generation, animateImageHydration);
2257
+ }
2258
+ }
1899
2259
  /**
1900
2260
  * Groups tokens into chunks and mounts them: an initial batch synchronously
1901
2261
  * (enough for a small document to behave exactly like the non-chunked
@@ -1911,41 +2271,111 @@ export class MdzipWorkspaceView {
1911
2271
  this.elPreviewContent.replaceChildren();
1912
2272
  // Also called with no options in renderFullDocumentHtml — see the
1913
2273
  // comment there; keep both call sites' chunking in sync.
1914
- const chunks = groupTokensIntoChunks(tokens);
2274
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
1915
2275
  if (chunks.length === 0) {
1916
2276
  this.chunkedRenderState = null;
1917
2277
  this.firePreviewRendered(snapshot, generation);
1918
2278
  this.fireAssetsHydrated(snapshot, generation);
1919
2279
  return;
1920
2280
  }
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) => {
2281
+ const records = chunks.map((chunkTokens) => ({
2282
+ key: chunkSourceKey(chunkTokens),
2283
+ tokens: chunkTokens,
2284
+ root: null,
2285
+ html: null,
2286
+ handles: []
2287
+ }));
2288
+ this.chunkedRenderState = {
2289
+ records, cursor: 0, context, generation, animateImageHydration, sentinelHandle: null,
2290
+ // Set for the whole initial batch/eager mount below — see the field's
2291
+ // doc comment: an edit landing before this settles must not reconcile
2292
+ // against a `cursor` that doesn't yet reflect this in-flight mount.
2293
+ mounting: true
2294
+ };
2295
+ if (this.progressiveTextRendering) {
2296
+ // Mount an initial batch synchronously (enough for a small document to
2297
+ // behave exactly like a non-progressive mount), then lazily continue
2298
+ // the rest as the user scrolls near a trailing sentinel.
2299
+ void this.mountChunkBatch(records, 0, context, generation, animateImageHydration, () => {
2300
+ this.fireAssetsHydrated(snapshot, generation);
2301
+ }).then((cursor) => {
2302
+ // See armChunkSentinel's mountNext for why this is recorded before
2303
+ // the staleness check below.
2304
+ this.recordChunkProgress(generation, records, cursor);
2305
+ if (generation !== this.previewGeneration || context.signal.aborted)
2306
+ return;
2307
+ if (this.chunkedRenderState?.generation === generation) {
2308
+ this.chunkedRenderState.mounting = false;
2309
+ }
2310
+ this.firePreviewRendered(snapshot, generation);
2311
+ if (cursor < records.length) {
2312
+ this.armChunkSentinel(records, cursor, context, generation, animateImageHydration);
2313
+ }
2314
+ });
2315
+ return;
2316
+ }
2317
+ // Not opted into lazy-load-on-scroll: mount every chunk up front, same
2318
+ // visible end state as the old monolithic non-chunked path, but yielding
2319
+ // a frame between batches (mirroring drainRemainingChunks) instead of
2320
+ // blocking the main thread for one giant synchronous render — see the
2321
+ // plan's disclosed cold-start tradeoff.
2322
+ void this.mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot);
2323
+ }
2324
+ /**
2325
+ * Mounts every chunk of a non-progressive (`progressiveTextRendering:
2326
+ * false`) document up front — same end state as the old monolithic
2327
+ * non-chunked path (the whole document mounted before `onPreviewRendered`
2328
+ * fires), but yielding a frame between batches (mirroring
2329
+ * `drainRemainingChunks`) instead of blocking the main thread for one
2330
+ * giant synchronous render.
2331
+ */
2332
+ async mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot) {
2333
+ const allPending = [];
2334
+ while (generation === this.previewGeneration && !context.signal.aborted) {
2335
+ const cursorBefore = this.chunkedRenderState?.generation === generation
2336
+ ? this.chunkedRenderState.cursor
2337
+ : records.length;
2338
+ if (cursorBefore >= records.length)
2339
+ break;
2340
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursorBefore, context, generation);
2341
+ // See armChunkSentinel's mountNext for why this is recorded before the
2342
+ // staleness check: the chunks this batch appended are physically in
2343
+ // the DOM regardless of whether a newer edit has since landed, and
2344
+ // recordChunkProgress's own generation check keeps this a safe no-op
2345
+ // once chunkedRenderState has moved on.
2346
+ this.recordChunkProgress(generation, records, cursor);
1925
2347
  if (generation !== this.previewGeneration || context.signal.aborted)
1926
2348
  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);
2349
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
2350
+ if (cursor >= records.length)
2351
+ break;
2352
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
2353
+ }
2354
+ if (generation === this.previewGeneration && !context.signal.aborted) {
2355
+ if (this.chunkedRenderState?.generation === generation) {
2356
+ this.chunkedRenderState.mounting = false;
1931
2357
  }
1932
- });
2358
+ this.firePreviewRendered(snapshot, generation);
2359
+ this.hydrateImages(allPending, context, generation, animateImageHydration, () => {
2360
+ this.fireAssetsHydrated(snapshot, generation);
2361
+ });
2362
+ }
1933
2363
  }
1934
2364
  /**
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.
2365
+ * Updates `chunkedRenderState.cursor` after a batch mounts. A no-op if a
2366
+ * newer render generation has already superseded this one. Deliberately
2367
+ * never nulls `chunkedRenderState` out just because `cursor` reached
2368
+ * `records.length` — the records stay alive for the whole generation so a
2369
+ * later same-document edit can reconcile against them (see
2370
+ * `applyChunkReconciliation`). Callers that need "is there still an
2371
+ * unmounted lazy tail" (Copy All's instant path, the scroll-to-bottom fast
2372
+ * path) check `cursor < records.length` directly instead of this field's
2373
+ * truthiness.
1939
2374
  */
1940
- recordChunkProgress(generation, chunks, cursor) {
2375
+ recordChunkProgress(generation, records, cursor) {
1941
2376
  if (this.chunkedRenderState?.generation !== generation)
1942
2377
  return;
1943
- if (cursor >= chunks.length) {
1944
- this.chunkedRenderState = null;
1945
- }
1946
- else {
1947
- this.chunkedRenderState.cursor = cursor;
1948
- }
2378
+ this.chunkedRenderState.cursor = cursor;
1949
2379
  }
1950
2380
  /**
1951
2381
  * Renders+appends chunks starting at `startCursor` up to a char budget
@@ -1964,7 +2394,12 @@ export class MdzipWorkspaceView {
1964
2394
  * included here; see {@link mountChunkBatch} and
1965
2395
  * {@link drainRemainingChunks} for the two different ways callers pace it.
1966
2396
  */
1967
- async renderAndMountChunkBatch(chunks, startCursor, context, generation) {
2397
+ async renderAndMountChunkBatch(records, startCursor, context, generation, insertBeforeNode = null,
2398
+ // Exclusive upper bound. Reconciliation mounts just its replacement
2399
+ // "middle"; the records after it are surviving suffix chunks that are
2400
+ // already in the DOM, and running on into them would mount each a second
2401
+ // time (orphaning the first copy's root, which nothing would ever remove).
2402
+ endCursor = records.length) {
1968
2403
  const BATCH_CHAR_BUDGET = 4000;
1969
2404
  const BATCH_TIME_BUDGET_MS = 10;
1970
2405
  const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
@@ -1972,12 +2407,13 @@ export class MdzipWorkspaceView {
1972
2407
  let cursor = startCursor;
1973
2408
  let renderedChars = 0;
1974
2409
  const mountedRoots = [];
1975
- while (cursor < chunks.length
2410
+ const batchStartCursor = startCursor;
2411
+ while (cursor < Math.min(endCursor, records.length)
1976
2412
  && renderedChars < BATCH_CHAR_BUDGET
1977
2413
  && (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
1978
2414
  if (generation !== this.previewGeneration || context.signal.aborted)
1979
2415
  return { cursor, mountedRoots };
1980
- const chunkTokens = chunks[cursor];
2416
+ const chunkTokens = records[cursor].tokens;
1981
2417
  const chunkIndex = cursor;
1982
2418
  cursor += 1;
1983
2419
  let html;
@@ -2011,15 +2447,19 @@ export class MdzipWorkspaceView {
2011
2447
  }
2012
2448
  this.chunkHtmlCache.html.set(chunkIndex, html);
2013
2449
  }
2014
- const root = this.appendChunkHtml(html);
2450
+ const root = this.appendChunkHtml(html, insertBeforeNode);
2451
+ records[chunkIndex].root = root;
2452
+ records[chunkIndex].html = html;
2015
2453
  mountedRoots.push(root);
2016
2454
  renderedChars += html.length;
2017
2455
  }
2018
- for (const root of mountedRoots) {
2019
- this.mountPreviewExtensions(context, generation, root);
2020
- const codeBlockHandle = this.mountCodeBlockControls(root);
2456
+ for (const record of records.slice(batchStartCursor, cursor)) {
2457
+ if (!record.root)
2458
+ continue;
2459
+ this.mountPreviewExtensions(context, generation, record.root, record.handles);
2460
+ const codeBlockHandle = this.mountCodeBlockControls(record.root);
2021
2461
  if (codeBlockHandle) {
2022
- this.previewHandles.push(codeBlockHandle);
2462
+ record.handles.push(codeBlockHandle);
2023
2463
  }
2024
2464
  }
2025
2465
  return { cursor, mountedRoots };
@@ -2033,21 +2473,31 @@ export class MdzipWorkspaceView {
2033
2473
  * by how fast the user scrolls, so one independent hydration loop per
2034
2474
  * batch never has a chance to pile up against another.
2035
2475
  */
2036
- async mountChunkBatch(chunks, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2037
- const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, startCursor, context, generation);
2476
+ async mountChunkBatch(records, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2477
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, startCursor, context, generation);
2038
2478
  // Same relative order as the non-chunked path: cheap image pass, then
2039
2479
  // the (expensive) slot/observe pass.
2040
- const pending = this.collectPendingImages(mountedRoots, animateImageHydration);
2480
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2041
2481
  this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
2042
2482
  return cursor;
2043
2483
  }
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) {
2484
+ /**
2485
+ * Wraps one chunk's rendered HTML in a mount boundary and appends it (or,
2486
+ * given `beforeNode`, splices it in ahead of that still-attached node —
2487
+ * used by reconciliation to insert a replacement chunk ahead of a
2488
+ * surviving mounted suffix). See the `.mdzip-chunk` CSS rules for why.
2489
+ */
2490
+ appendChunkHtml(html, beforeNode = null) {
2046
2491
  const doc = this.elPreviewContent.ownerDocument;
2047
2492
  const wrapper = doc.createElement('div');
2048
2493
  wrapper.className = 'mdzip-chunk';
2049
2494
  wrapper.innerHTML = html;
2050
- this.elPreviewContent.appendChild(wrapper);
2495
+ if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
2496
+ this.elPreviewContent.insertBefore(wrapper, beforeNode);
2497
+ }
2498
+ else {
2499
+ this.elPreviewContent.appendChild(wrapper);
2500
+ }
2051
2501
  return wrapper;
2052
2502
  }
2053
2503
  /**
@@ -2058,17 +2508,36 @@ export class MdzipWorkspaceView {
2058
2508
  * every chunk is mounted. Falls back to mounting everything immediately
2059
2509
  * when IntersectionObserver isn't available, matching `hydrateImages`.
2060
2510
  */
2061
- armChunkSentinel(chunks, cursor, context, generation, animateImageHydration) {
2511
+ armChunkSentinel(records, cursor, context, generation, animateImageHydration) {
2062
2512
  const doc = this.elPreviewContent.ownerDocument;
2063
2513
  const observerWindow = doc.defaultView;
2064
2514
  const mountNext = (nextCursor) => {
2065
- void this.mountChunkBatch(chunks, nextCursor, context, generation, animateImageHydration, () => { })
2515
+ // Same race this field guards against everywhere else: an edit landing
2516
+ // while this scroll-triggered continuation is mid-flight must not
2517
+ // reconcile against a cursor that doesn't yet reflect it.
2518
+ if (this.chunkedRenderState?.generation === generation) {
2519
+ this.chunkedRenderState.mounting = true;
2520
+ }
2521
+ void this.mountChunkBatch(records, nextCursor, context, generation, animateImageHydration, () => { })
2066
2522
  .then((newCursor) => {
2523
+ // Record whatever this batch actually mounted regardless of
2524
+ // whether a newer edit has since landed — recordChunkProgress's
2525
+ // own generation check already makes this a safe no-op once
2526
+ // chunkedRenderState has moved on. Skipping this call when stale
2527
+ // was a real bug: the chunks this batch appended are physically in
2528
+ // the DOM either way, but a newer edit's reconcile still saw the
2529
+ // *old*, un-advanced cursor and re-armed a sentinel from the same
2530
+ // starting point — re-rendering and re-appending (duplicating)
2531
+ // chunks that were already mounted, visible as an already-settled
2532
+ // image flashing back to broken as a fresh duplicate replaced it.
2533
+ this.recordChunkProgress(generation, records, newCursor);
2067
2534
  if (generation !== this.previewGeneration || context.signal.aborted)
2068
2535
  return;
2069
- this.recordChunkProgress(generation, chunks, newCursor);
2070
- if (newCursor < chunks.length) {
2071
- this.armChunkSentinel(chunks, newCursor, context, generation, animateImageHydration);
2536
+ if (this.chunkedRenderState?.generation === generation) {
2537
+ this.chunkedRenderState.mounting = false;
2538
+ }
2539
+ if (newCursor < records.length) {
2540
+ this.armChunkSentinel(records, newCursor, context, generation, animateImageHydration);
2072
2541
  }
2073
2542
  });
2074
2543
  };
@@ -2169,7 +2638,7 @@ export class MdzipWorkspaceView {
2169
2638
  * appended chunk rather than once over the whole document. Defaults to
2170
2639
  * the whole preview content, matching the non-chunked path exactly.
2171
2640
  */
2172
- mountPreviewExtensions(context, generation, root = this.elPreviewContent) {
2641
+ mountPreviewExtensions(context, generation, root = this.elPreviewContent, collector = this.previewHandles) {
2173
2642
  for (const extension of this.markdownExtensions) {
2174
2643
  if (!extension.mount) {
2175
2644
  continue;
@@ -2190,7 +2659,7 @@ export class MdzipWorkspaceView {
2190
2659
  }
2191
2660
  return;
2192
2661
  }
2193
- this.previewHandles.push(handle);
2662
+ collector.push(handle);
2194
2663
  }).catch((error) => {
2195
2664
  if (generation === this.previewGeneration) {
2196
2665
  this.options.onFailed?.(error);
@@ -2198,7 +2667,7 @@ export class MdzipWorkspaceView {
2198
2667
  });
2199
2668
  }
2200
2669
  else if (mounted) {
2201
- this.previewHandles.push(mounted);
2670
+ collector.push(mounted);
2202
2671
  }
2203
2672
  }
2204
2673
  catch (error) {
@@ -2337,6 +2806,8 @@ export class MdzipWorkspaceView {
2337
2806
  if (generation !== this.previewGeneration) {
2338
2807
  return;
2339
2808
  }
2809
+ this.restorePendingPreviewScroll(generation);
2810
+ this.flushPendingAnchorSoon();
2340
2811
  try {
2341
2812
  this.options.onPreviewRendered?.(snapshot);
2342
2813
  }
@@ -2344,6 +2815,20 @@ export class MdzipWorkspaceView {
2344
2815
  this.options.onFailed?.(error);
2345
2816
  }
2346
2817
  }
2818
+ /** See `pendingPreviewScrollRestore`'s doc comment. */
2819
+ restorePendingPreviewScroll(generation) {
2820
+ const pending = this.pendingPreviewScrollRestore;
2821
+ if (!pending || pending.generation !== generation) {
2822
+ return;
2823
+ }
2824
+ this.pendingPreviewScrollRestore = null;
2825
+ if (this.elPreviewPane.scrollTop !== pending.scrollTop) {
2826
+ this.syncing = true;
2827
+ this.lastSyncedPreviewScrollTop = pending.scrollTop;
2828
+ this.elPreviewPane.scrollTop = pending.scrollTop;
2829
+ this.syncing = false;
2830
+ }
2831
+ }
2347
2832
  fireAssetsHydrated(snapshot, generation) {
2348
2833
  if (generation !== this.previewGeneration) {
2349
2834
  return;
@@ -2648,6 +3133,19 @@ export class MdzipWorkspaceView {
2648
3133
  // Attach scroll listener to sync with preview
2649
3134
  const scroller = editor.dom.querySelector('.cm-scroller');
2650
3135
  if (scroller) {
3136
+ // No 'keydown' here, deliberately: .cm-scroller receives every keystroke
3137
+ // typed into the editor, not just navigation keys, so it can't tell
3138
+ // "user pressed PageDown to scroll" apart from "user typed a letter" —
3139
+ // confirmed live, where ordinary typing was arming this gesture window
3140
+ // and letting CodeMirror's own small post-edit viewport-correction
3141
+ // scroll (a real native behavior) through as if it were a deliberate
3142
+ // scroll, on every keystroke. Wheel/touch/mousedown (which also covers
3143
+ // a scrollbar-thumb drag, since that lands on this same element) are
3144
+ // unambiguous and cover the vast majority of real scrolling.
3145
+ const markEditorGesture = () => { self.lastEditorGestureTime = performance.now(); };
3146
+ scroller.addEventListener('wheel', markEditorGesture, { passive: true });
3147
+ scroller.addEventListener('touchstart', markEditorGesture, { passive: true });
3148
+ scroller.addEventListener('mousedown', markEditorGesture);
2651
3149
  scroller.addEventListener('scroll', () => self.syncScrollToPreview());
2652
3150
  }
2653
3151
  editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
@@ -3331,7 +3829,27 @@ export class MdzipWorkspaceView {
3331
3829
  const clampedTop = Math.max(margin, Math.min(naturalTop, win.innerHeight - margin - submenu.offsetHeight));
3332
3830
  submenu.style.top = `${clampedTop - rect.top}px`;
3333
3831
  });
3832
+ // Chromium treats a Shift+mousedown as "extend the selection", right
3833
+ // button included, so Shift+right-click would first stretch the selection
3834
+ // from the caret to the click — and the native menu reports no misspelled
3835
+ // word (hence no suggestions) unless the selection is collapsed or just
3836
+ // the word. Cancelling that mousedown leaves the selection alone and lets
3837
+ // the browser resolve the word under the pointer itself. Capture phase so
3838
+ // it runs before CodeMirror's own handlers; focus is restored by hand
3839
+ // since the cancelled mousedown no longer moves it.
3840
+ this.elEditorHost.addEventListener('mousedown', (e) => {
3841
+ if (e.button === 2 && e.shiftKey) {
3842
+ e.preventDefault();
3843
+ this.cmEditor?.focus();
3844
+ }
3845
+ }, true);
3334
3846
  this.elEditorHost.addEventListener('contextmenu', (e) => {
3847
+ // Shift+right-click bypasses our own formatting menu so the host's
3848
+ // native context menu (spell-check suggestions, etc.) shows instead —
3849
+ // without this, preventDefault() below suppresses it unconditionally.
3850
+ if (e.shiftKey) {
3851
+ return;
3852
+ }
3335
3853
  if (!this.controlPolicy.contextMenu.editor) {
3336
3854
  return;
3337
3855
  }
@@ -3421,6 +3939,11 @@ export class MdzipWorkspaceView {
3421
3939
  void this.replaceFileFromPicker(path, file);
3422
3940
  }
3423
3941
  });
3942
+ const markPreviewGesture = () => { this.lastPreviewGestureTime = performance.now(); };
3943
+ this.elPreviewPane.addEventListener('wheel', markPreviewGesture, { passive: true });
3944
+ this.elPreviewPane.addEventListener('touchstart', markPreviewGesture, { passive: true });
3945
+ this.elPreviewPane.addEventListener('mousedown', markPreviewGesture);
3946
+ this.elPreviewPane.addEventListener('keydown', markPreviewGesture);
3424
3947
  this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
3425
3948
  // Gives the pane logical focus on click so a subsequent Ctrl/Cmd+A (below)
3426
3949
  // can be scoped to it. tabindex="-1" keeps it out of Tab order; this is
@@ -3434,12 +3957,24 @@ export class MdzipWorkspaceView {
3434
3957
  if (!link || !snapshot) {
3435
3958
  return;
3436
3959
  }
3437
- const targetPath = resolveMdzipArchiveLinkTarget(link.getAttribute('href') ?? '', snapshot.currentPath, snapshot.content.paths);
3438
- if (!targetPath) {
3960
+ const href = link.getAttribute('href') ?? '';
3961
+ if (href.startsWith('#')) {
3962
+ event.preventDefault();
3963
+ void this.scrollPreviewToAnchor(href.slice(1));
3439
3964
  return;
3440
3965
  }
3441
- event.preventDefault();
3442
- void this.openPath(targetPath);
3966
+ const targetPath = resolveMdzipArchiveLinkTarget(href, snapshot.currentPath, snapshot.content.paths);
3967
+ if (targetPath) {
3968
+ event.preventDefault();
3969
+ const hashIndex = href.indexOf('#');
3970
+ const fragment = hashIndex >= 0 ? href.slice(hashIndex + 1) : '';
3971
+ void this.openPathAndScrollToAnchor(targetPath, fragment);
3972
+ return;
3973
+ }
3974
+ if (this.options.onUnresolvedLinkClick && isMdzipWorkspaceRelativeLink(href)) {
3975
+ event.preventDefault();
3976
+ this.options.onUnresolvedLinkClick(href, snapshot);
3977
+ }
3443
3978
  });
3444
3979
  this.elRoot.addEventListener('pointerover', (event) => this.handleTooltipPointer(event));
3445
3980
  this.elRoot.addEventListener('pointerout', (event) => {
@@ -3473,6 +4008,99 @@ export class MdzipWorkspaceView {
3473
4008
  element.removeAttribute('title');
3474
4009
  });
3475
4010
  }
4011
+ async openPathAndScrollToAnchor(path, fragment) {
4012
+ const generationBefore = this.previewGeneration;
4013
+ this.pendingAnchorFragment = fragment || null;
4014
+ await this.openPath(path);
4015
+ // No new render started (already on that document, or the open failed):
4016
+ // nothing will call firePreviewRendered, so apply the anchor now.
4017
+ if (this.pendingAnchorFragment !== null && this.previewGeneration === generationBefore) {
4018
+ const pending = this.pendingAnchorFragment;
4019
+ this.pendingAnchorFragment = null;
4020
+ await this.scrollPreviewToAnchor(pending);
4021
+ }
4022
+ }
4023
+ // Deferred a frame: firePreviewRendered runs inside the first chunk batch's
4024
+ // completion callback, before the lazy-continuation sentinel for the rest is
4025
+ // armed. Scrolling then (which may need to drain those chunks) would race it.
4026
+ flushPendingAnchorSoon() {
4027
+ if (this.pendingAnchorFragment === null) {
4028
+ return;
4029
+ }
4030
+ const generation = this.previewGeneration;
4031
+ requestAnimationFrame(() => {
4032
+ if (generation !== this.previewGeneration || this.pendingAnchorFragment === null) {
4033
+ return;
4034
+ }
4035
+ const pending = this.pendingAnchorFragment;
4036
+ this.pendingAnchorFragment = null;
4037
+ void this.scrollPreviewToAnchor(pending);
4038
+ });
4039
+ }
4040
+ findPreviewAnchor(fragment) {
4041
+ const wanted = new Set([fragment, MDZIP_HEADING_ID_PREFIX + fragment]);
4042
+ const wantedLower = new Set([...wanted].map((value) => value.toLowerCase()));
4043
+ let caseInsensitiveMatch = null;
4044
+ for (const element of Array.from(this.elPreviewContent.querySelectorAll('[id],[name]'))) {
4045
+ for (const value of [element.getAttribute('id'), element.getAttribute('name')]) {
4046
+ if (!value)
4047
+ continue;
4048
+ if (wanted.has(value))
4049
+ return element;
4050
+ if (!caseInsensitiveMatch && wantedLower.has(value.toLowerCase()))
4051
+ caseInsensitiveMatch = element;
4052
+ }
4053
+ }
4054
+ return caseInsensitiveMatch;
4055
+ }
4056
+ recordMayContainAnchor(record, fragment) {
4057
+ const lower = fragment.toLowerCase();
4058
+ if (collectMdzipHeadingIds(record.tokens).some((id) => id.toLowerCase() === lower)) {
4059
+ return true;
4060
+ }
4061
+ // An explicit <a id="x"> / <a name="x"> anchor written in the source.
4062
+ const escaped = fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
4063
+ return new RegExp(`(?:id|name)\\s*=\\s*["']${escaped}["']`, 'i').test(record.key);
4064
+ }
4065
+ /**
4066
+ * Scrolls the preview to the heading (or explicit id/name anchor) a
4067
+ * `#fragment` link names. Under progressive rendering the target may sit in
4068
+ * a chunk that hasn't been mounted yet, so this mounts everything up to and
4069
+ * including that chunk first. Returns whether an anchor was found.
4070
+ */
4071
+ async scrollPreviewToAnchor(rawFragment) {
4072
+ let fragment = rawFragment;
4073
+ try {
4074
+ fragment = decodeURIComponent(rawFragment);
4075
+ }
4076
+ catch {
4077
+ // Malformed escape — use it as written.
4078
+ }
4079
+ let target = fragment ? this.findPreviewAnchor(fragment) : null;
4080
+ const pending = this.chunkedRenderState;
4081
+ if (!target && fragment && pending && pending.cursor < pending.records.length) {
4082
+ const index = pending.records.findIndex((record, i) => i >= pending.cursor && this.recordMayContainAnchor(record, fragment));
4083
+ if (index >= 0) {
4084
+ await this.drainRemainingChunks(pending, () => { }, new AbortController().signal, index);
4085
+ const remaining = this.chunkedRenderState;
4086
+ if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle
4087
+ && remaining.cursor < remaining.records.length) {
4088
+ this.armChunkSentinel(remaining.records, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
4089
+ }
4090
+ target = this.findPreviewAnchor(fragment);
4091
+ }
4092
+ }
4093
+ const pane = this.elPreviewPane;
4094
+ if (!target) {
4095
+ // `#` and `#top` mean the top of the document, as in a browser.
4096
+ if (!fragment || fragment.toLowerCase() === 'top') {
4097
+ pane.scrollTop = 0;
4098
+ }
4099
+ return false;
4100
+ }
4101
+ pane.scrollTop += target.getBoundingClientRect().top - pane.getBoundingClientRect().top;
4102
+ return true;
4103
+ }
3476
4104
  async openPath(path) {
3477
4105
  if (!this.workspace) {
3478
4106
  return;
@@ -4240,7 +4868,7 @@ export class MdzipWorkspaceView {
4240
4868
  // spelling suggestions live — there's no API to read the browser's
4241
4869
  // dictionary suggestions into a custom menu. Point at the escape hatch
4242
4870
  // instead of silently dropping the feature.
4243
- if (editable) {
4871
+ if (editable && this.options.showSpellingSuggestionsHint !== false) {
4244
4872
  groups.push([{
4245
4873
  action: 'editor-spelling-suggestions-hint',
4246
4874
  label: 'Spelling Suggestions',
@@ -4460,20 +5088,22 @@ export class MdzipWorkspaceView {
4460
5088
  * Copies the entire rendered document as plain text (same fidelity as
4461
5089
  * `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
4462
5090
  * 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.
5091
+ * everything's already mounted (small doc, eager/non-progressive mount, or
5092
+ * the user already scrolled through it) this is instant —
5093
+ * `chunkedRenderState` is either null (no markdown open, or a non-default
5094
+ * renderer) or has nothing left to mount (`cursor >= records.length`) in
5095
+ * exactly that case. Otherwise it force-drains the rest first, showing a
5096
+ * cancelable progress dialog once the wait clears a short debounce so fast
5097
+ * documents never flicker it into view.
4468
5098
  */
4469
5099
  async copyAllPreviewContent() {
4470
5100
  const pending = this.chunkedRenderState;
4471
- if (!pending) {
5101
+ if (!pending || pending.cursor >= pending.records.length) {
4472
5102
  const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
4473
5103
  this.finishCopyNotification(false, outcome);
4474
5104
  return;
4475
5105
  }
4476
- const total = pending.chunks.length;
5106
+ const total = pending.records.length;
4477
5107
  const abort = new AbortController();
4478
5108
  let dialogShown = false;
4479
5109
  const label = 'Rendering the full document';
@@ -4503,8 +5133,9 @@ export class MdzipWorkspaceView {
4503
5133
  // Cancelled partway through — whatever's left stays unmounted, so
4504
5134
  // re-arm the usual scroll-driven continuation for it.
4505
5135
  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);
5136
+ if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle
5137
+ && remaining.cursor < remaining.records.length) {
5138
+ this.armChunkSentinel(remaining.records, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
4508
5139
  }
4509
5140
  return;
4510
5141
  }
@@ -4546,7 +5177,7 @@ export class MdzipWorkspaceView {
4546
5177
  // renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
4547
5178
  // call sites' chunking in sync, since chunk index is chunkHtmlCache's
4548
5179
  // only key.
4549
- const chunks = groupTokensIntoChunks(tokens);
5180
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
4550
5181
  const view = this.elPreviewContent.ownerDocument.defaultView;
4551
5182
  const clock = view?.performance ?? performance;
4552
5183
  const BATCH_TIME_BUDGET_MS = 10;
@@ -4760,33 +5391,42 @@ export class MdzipWorkspaceView {
4760
5391
  * pass over every image at the end avoids the pile-up entirely, without
4761
5392
  * making Copy All wait on it.
4762
5393
  */
4763
- async drainRemainingChunks(state, onProgress, signal) {
4764
- const { chunks, context, generation, animateImageHydration } = state;
5394
+ async drainRemainingChunks(state, onProgress, signal, stopAfterRecordIndex) {
5395
+ const { records, context, generation, animateImageHydration } = state;
4765
5396
  // Draining is instead of the scroll-driven continuation, not alongside
4766
5397
  // it — tearing down any armed sentinel first stops the two from racing
4767
5398
  // and double-mounting the same chunk.
4768
5399
  state.sentinelHandle?.destroy();
4769
5400
  if (this.chunkedRenderState?.generation === generation) {
4770
5401
  this.chunkedRenderState.sentinelHandle = null;
5402
+ // Same race the `mounting` field guards against elsewhere: an edit
5403
+ // landing mid-drain must not reconcile against a cursor this loop is
5404
+ // actively advancing out from under it.
5405
+ this.chunkedRenderState.mounting = true;
4771
5406
  }
4772
5407
  const allPending = [];
4773
5408
  try {
4774
- while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < chunks.length) {
5409
+ while (this.chunkedRenderState?.generation === generation
5410
+ && this.chunkedRenderState.cursor < records.length
5411
+ && (stopAfterRecordIndex === undefined || this.chunkedRenderState.cursor <= stopAfterRecordIndex)) {
4775
5412
  if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
4776
5413
  return;
4777
5414
  const cursor = this.chunkedRenderState.cursor;
4778
- const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, cursor, context, generation);
5415
+ const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursor, context, generation);
4779
5416
  if (generation !== this.previewGeneration || context.signal.aborted)
4780
5417
  return;
4781
- allPending.push(...this.collectPendingImages(mountedRoots, animateImageHydration));
4782
- this.recordChunkProgress(generation, chunks, newCursor);
4783
- onProgress(newCursor, chunks.length);
5418
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
5419
+ this.recordChunkProgress(generation, records, newCursor);
5420
+ onProgress(newCursor, records.length);
4784
5421
  if (signal.aborted)
4785
5422
  return;
4786
5423
  await new Promise((resolve) => requestAnimationFrame(() => resolve()));
4787
5424
  }
4788
5425
  }
4789
5426
  finally {
5427
+ if (this.chunkedRenderState?.generation === generation) {
5428
+ this.chunkedRenderState.mounting = false;
5429
+ }
4790
5430
  if (generation === this.previewGeneration && !context.signal.aborted) {
4791
5431
  this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
4792
5432
  }
@@ -5757,6 +6397,12 @@ export class MdzipWorkspaceView {
5757
6397
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
5758
6398
  return;
5759
6399
  }
6400
+ if (performance.now() - this.lastPreviewGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6401
+ return;
6402
+ }
6403
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6404
+ return;
6405
+ }
5760
6406
  const currentTop = this.elPreviewPane.scrollTop;
5761
6407
  // Recognize this event as the echo of our own prior write (from
5762
6408
  // syncScrollToPreview) by comparing values instead of racing timing.
@@ -5787,6 +6433,12 @@ export class MdzipWorkspaceView {
5787
6433
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
5788
6434
  return;
5789
6435
  }
6436
+ if (performance.now() - this.lastEditorGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6437
+ return;
6438
+ }
6439
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6440
+ return;
6441
+ }
5790
6442
  const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
5791
6443
  if (!cmScroller) {
5792
6444
  return;
@@ -5813,8 +6465,23 @@ export class MdzipWorkspaceView {
5813
6465
  // CodeMirror has actually decided to draw for the current scroll
5814
6466
  // position, so comparing it to the document length is exact regardless
5815
6467
  // of any height estimation drift.
5816
- const atDocEnd = this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
5817
- if (atDocEnd && this.chunkedRenderState) {
6468
+ //
6469
+ // `viewport.to >= doc.length` alone is a false positive whenever the
6470
+ // whole document already fits within the editor's visible area — there
6471
+ // is no scrolling at all in that case, so `viewport.to` trivially covers
6472
+ // the full document from position 0 regardless of scroll intent. Without
6473
+ // the overflow check below, a short-to-medium document (especially one
6474
+ // with images, which slow down per-chunk mounting enough to leave a real
6475
+ // window where `cursor < records.length`) could hit this branch off of
6476
+ // an incidental scroll event fired mid-edit — e.g. focus or cursor
6477
+ // movement nudging `scrollTop` by a sub-pixel amount — and force-drain
6478
+ // plus jump the preview to its bottom for no reason the user asked for,
6479
+ // which (via the echo-breaking reflow that follows, since late-arriving
6480
+ // image layout shifts the "bottom" after the jump) can drag the editor's
6481
+ // own scroll down to match through `syncScrollFromPreview`'s echo path.
6482
+ const hasScrollableOverflow = editorHeight > 0;
6483
+ const atDocEnd = hasScrollableOverflow && this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
6484
+ if (atDocEnd && this.chunkedRenderState && this.chunkedRenderState.cursor < this.chunkedRenderState.records.length) {
5818
6485
  void this.syncScrollToPreviewBottom();
5819
6486
  return;
5820
6487
  }
@@ -5857,7 +6524,7 @@ export class MdzipWorkspaceView {
5857
6524
  }
5858
6525
  let showToastTimer = setTimeout(() => {
5859
6526
  showToastTimer = null;
5860
- this.scrollCatchUpState = { done: state.cursor, total: state.chunks.length };
6527
+ this.scrollCatchUpState = { done: state.cursor, total: state.records.length };
5861
6528
  this.updateScrollCatchUpToast();
5862
6529
  }, 200);
5863
6530
  try {