@mdzip/editor 1.4.0 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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 +71 -1
  36. package/dist/rendering.d.ts.map +1 -1
  37. package/dist/rendering.js +62 -2
  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 +112 -10
  43. package/dist/view.d.ts.map +1 -1
  44. package/dist/view.js +940 -410
  45. package/dist/view.js.map +1 -1
  46. package/package.json +3 -1
package/dist/view.js CHANGED
@@ -14,7 +14,8 @@ 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
16
  import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
17
- import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks } from './rendering.js';
17
+ import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, 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;
@@ -745,19 +746,19 @@ depth = 0) {
745
746
  : isImageFile(node.entry.path)
746
747
  ? IMAGE_ICON_HTML
747
748
  : FILE_ICON_HTML;
748
- const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
749
- <span class="nav-orphan-button" role="button" tabindex="0"
750
- title="Orphaned asset" aria-label="Orphaned asset actions"
751
- data-orphan-path="${safePath}">
752
- ${ORPHAN_ICON_HTML}
749
+ const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
750
+ <span class="nav-orphan-button" role="button" tabindex="0"
751
+ title="Orphaned asset" aria-label="Orphaned asset actions"
752
+ data-orphan-path="${safePath}">
753
+ ${ORPHAN_ICON_HTML}
753
754
  </span>` : '';
754
755
  const draggable = options.allowDrag && !isManifest;
755
- return `<button type="button" class="${classes}" title="${title}"
756
- data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
757
- ${guides}<span class="nav-caret"></span>
758
- <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
759
- ${orphanBtnHtml}
760
- <span class="nav-label">${safeName}</span>
756
+ return `<button type="button" class="${classes}" title="${title}"
757
+ data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
758
+ ${guides}<span class="nav-caret"></span>
759
+ <span class="nav-file-icon ${iconKind}">${iconHtml}</span>
760
+ ${orphanBtnHtml}
761
+ <span class="nav-label">${safeName}</span>
761
762
  </button>`;
762
763
  }
763
764
  const childTrail = depth === 0 ? [] : [...trail, !isLast];
@@ -765,14 +766,14 @@ depth = 0) {
765
766
  .map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
766
767
  .join('');
767
768
  const pending = options.pendingFolders.has(node.path.toLowerCase());
768
- return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
769
- <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
770
- ${guides}<span class="nav-caret" aria-hidden="true"></span>
771
- <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
772
- <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
773
- <span class="nav-label">${escapeHtml(node.name)}</span>
774
- </summary>
775
- <div class="nav-directory-children">${children}</div>
769
+ return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
770
+ <summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
771
+ ${guides}<span class="nav-caret" aria-hidden="true"></span>
772
+ <span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
773
+ <span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
774
+ <span class="nav-label">${escapeHtml(node.name)}</span>
775
+ </summary>
776
+ <div class="nav-directory-children">${children}</div>
776
777
  </details>`;
777
778
  }
778
779
  // Inserts view-local pending (not yet saved) folders into the nav tree so they
@@ -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,52 @@ 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;
949
1005
  // Guards syncScrollToPreviewBottom against overlapping drains: set to the
950
1006
  // chunkedRenderState generation currently being force-drained, null when
951
1007
  // none is in flight. A second bottom-edge sync that arrives mid-drain
@@ -957,6 +1013,7 @@ export class MdzipWorkspaceView {
957
1013
  // debounce) — see syncScrollToPreviewBottom.
958
1014
  this.scrollCatchUpState = null;
959
1015
  this.markdownExtensions = [];
1016
+ this.frontMatterOptions = {};
960
1017
  this.entryRenderers = [];
961
1018
  this.renderingService = new MdzipRenderingService();
962
1019
  // Preview render memo: the preview pipeline only re-runs when one of these
@@ -991,8 +1048,9 @@ export class MdzipWorkspaceView {
991
1048
  this.navVisible = options.navigationButtonActive ?? this.navVisible;
992
1049
  this.markdownRenderer = options.markdownRenderer;
993
1050
  this.markdownExtensions = options.markdownExtensions ?? [];
1051
+ this.frontMatterOptions = options.frontMatter ?? {};
994
1052
  this.entryRenderers = options.entryRenderers ?? [];
995
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1053
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
996
1054
  injectStyles(container.ownerDocument);
997
1055
  container.replaceChildren();
998
1056
  container.innerHTML = SHELL_HTML;
@@ -1407,15 +1465,46 @@ export class MdzipWorkspaceView {
1407
1465
  if (options.markdownExtensions) {
1408
1466
  this.markdownExtensions = options.markdownExtensions;
1409
1467
  }
1468
+ if (options.frontMatter) {
1469
+ this.frontMatterOptions = options.frontMatter;
1470
+ }
1410
1471
  if (options.entryRenderers) {
1411
1472
  this.entryRenderers = options.entryRenderers;
1412
1473
  }
1413
- this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.markdownExtensions);
1474
+ this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
1414
1475
  this.resetPreviewState();
1415
1476
  this.teardownEntryRenderer();
1416
1477
  this.entryMatchMissKey = null;
1417
1478
  this.render();
1418
1479
  }
1480
+ /**
1481
+ * The extensions actually handed to `MdzipRenderingService`: the front
1482
+ * matter extension first (always on — see `frontMatterOptions`), then
1483
+ * whatever extensions the host passed via `markdownExtensions`. A fresh
1484
+ * front matter extension instance is created each time this runs (view
1485
+ * construction, `setRenderingOptions`), which is fine — its per-render
1486
+ * state lives in a `WeakMap` keyed by the render context, not on the
1487
+ * extension instance across renders.
1488
+ */
1489
+ pipelineMarkdownExtensions() {
1490
+ return [mdzipFrontMatterExtension(this.frontMatterOptions), ...this.markdownExtensions];
1491
+ }
1492
+ /**
1493
+ * Chunking options combining every registered extension's
1494
+ * {@link MdzipMarkdownRenderExtension.shouldIsolateChunk} — see that doc
1495
+ * comment for why a token an extension owns needs its own chunk regardless
1496
+ * of the size budget — with the same treatment built in for any token that
1497
+ * embeds an image ({@link tokenEmbedsImage}'s doc comment), plus a forced
1498
+ * chunk boundary at every heading ({@link tokenIsHeading}'s doc comment)
1499
+ * so unrelated sections never share a chunk.
1500
+ */
1501
+ chunkOptions() {
1502
+ const extensions = this.pipelineMarkdownExtensions();
1503
+ return {
1504
+ shouldIsolate: (token) => tokenEmbedsImage(token) || extensions.some((ext) => ext.shouldIsolateChunk?.(token)),
1505
+ shouldStartChunk: tokenIsHeading
1506
+ };
1507
+ }
1419
1508
  /**
1420
1509
  * Replaces the view control policy without recreating the workspace or
1421
1510
  * CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
@@ -1534,6 +1623,19 @@ export class MdzipWorkspaceView {
1534
1623
  this.options.onFailed?.(error);
1535
1624
  }
1536
1625
  }
1626
+ // Per-chunk handles live on their own records (see MdzipChunkRecord), not
1627
+ // in the flat array above — a full reset must still destroy every one of
1628
+ // them, the same as it always destroyed everything in previewHandles.
1629
+ for (const record of this.chunkedRenderState?.records ?? []) {
1630
+ for (const handle of record.handles) {
1631
+ try {
1632
+ handle.destroy();
1633
+ }
1634
+ catch (error) {
1635
+ this.options.onFailed?.(error);
1636
+ }
1637
+ }
1638
+ }
1537
1639
  // A new render generation invalidates any in-progress chunk draining —
1538
1640
  // Copy All's own abort check unwinds it and hides the dialog. It also
1539
1641
  // invalidates a "ready to copy" or "done" dialog left over from a
@@ -1556,6 +1658,10 @@ export class MdzipWorkspaceView {
1556
1658
  && memo.colorScheme === this.colorScheme;
1557
1659
  }
1558
1660
  updatePreview(snapshot, entryClaimed) {
1661
+ this.lastContentChangeTime = performance.now();
1662
+ // Captured before anything below can move it — see
1663
+ // `pendingPreviewScrollRestore`'s doc comment.
1664
+ const previewScrollTopBeforeUpdate = this.elPreviewPane.scrollTop;
1559
1665
  if (entryClaimed) {
1560
1666
  // The entry renderer owns the pane stack; release preview resources so
1561
1667
  // a later fallback re-renders from scratch.
@@ -1575,10 +1681,33 @@ export class MdzipWorkspaceView {
1575
1681
  return;
1576
1682
  }
1577
1683
  const animateImageHydration = this.shouldAnimateImageHydration(memo, snapshot);
1684
+ // A same-document, text-only edit with an existing chunked structure can
1685
+ // be reconciled against (only the chunks whose source actually changed
1686
+ // get touched) instead of torn down and rebuilt from scratch. Every
1687
+ // other case — first render, a path/pathType/colorScheme change, a
1688
+ // non-default renderer, a prior explicit reset that already cleared
1689
+ // chunkedRenderState, or an edit-triggered batch mount still in flight
1690
+ // for the current generation (`mounting` — see its doc comment: its
1691
+ // `cursor` doesn't yet reflect where that mount actually is, so
1692
+ // reconciling against it risks orphaned duplicate DOM) — falls through
1693
+ // to today's full reset + cold start, unchanged.
1694
+ const reconcileEligible = !!memo
1695
+ && memo.path === snapshot.currentPath
1696
+ && memo.pathType === snapshot.currentPathType
1697
+ && memo.colorScheme === this.colorScheme
1698
+ && snapshot.currentPathType === 'markdown'
1699
+ && this.renderingService.supportsChunking
1700
+ && !!this.chunkedRenderState
1701
+ && this.chunkedRenderState.generation === this.previewGeneration
1702
+ && !this.chunkedRenderState.mounting;
1703
+ const priorChunkedState = reconcileEligible ? this.chunkedRenderState : null;
1578
1704
  this.previewAbort?.abort();
1579
1705
  this.previewAbort = null;
1580
- this.destroyPreviewHandles();
1706
+ if (!priorChunkedState) {
1707
+ this.destroyPreviewHandles();
1708
+ }
1581
1709
  const generation = ++this.previewGeneration;
1710
+ this.pendingPreviewScrollRestore = { scrollTop: previewScrollTopBeforeUpdate, generation };
1582
1711
  this.previewHydrated = false;
1583
1712
  this.previewMemo = {
1584
1713
  path: snapshot.currentPath,
@@ -1610,8 +1739,13 @@ export class MdzipWorkspaceView {
1610
1739
  const abort = new AbortController();
1611
1740
  this.previewAbort = abort;
1612
1741
  const context = this.createMarkdownContext(snapshot, abort.signal);
1613
- if (this.progressiveTextRendering && this.renderingService.supportsChunking) {
1614
- this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1742
+ if (this.renderingService.supportsChunking) {
1743
+ if (priorChunkedState) {
1744
+ this.reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorChunkedState);
1745
+ }
1746
+ else {
1747
+ this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
1748
+ }
1615
1749
  return;
1616
1750
  }
1617
1751
  let result;
@@ -1689,7 +1823,7 @@ export class MdzipWorkspaceView {
1689
1823
  // controls mount, same relative order as always — see
1690
1824
  // collectPendingImages for why it's split from the (expensive) slot
1691
1825
  // creation that follows.
1692
- const pending = this.collectPendingImages([this.elPreviewContent], animateImageHydration);
1826
+ const pending = this.collectPendingImages([this.elPreviewContent], context, generation, animateImageHydration);
1693
1827
  this.mountPreviewExtensions(context, generation);
1694
1828
  const codeBlockHandle = this.mountCodeBlockControls();
1695
1829
  if (codeBlockHandle) {
@@ -1709,8 +1843,19 @@ export class MdzipWorkspaceView {
1709
1843
  * class, if any — they never get a slot). Returns `[]` without touching
1710
1844
  * anything when there's no asset session to resolve archive images
1711
1845
  * against, matching `mountPreviewHtml`'s no-op image handling.
1846
+ *
1847
+ * An image this session has *already* resolved once (e.g. it was already
1848
+ * on screen before an edit elsewhere caused its chunk to remount) is
1849
+ * applied immediately here via `resolveKnownImage` — no slot, no loading
1850
+ * class, no `hydrateImages`/`IntersectionObserver` round trip — instead of
1851
+ * being handed to the async placeholder-then-swap path meant for an image
1852
+ * that's genuinely being resolved for the first time. Skipping that path
1853
+ * for a re-mount of an already-known image is what actually avoids the
1854
+ * visible flash; still going through it (even instantly) briefly blanks
1855
+ * the image, since a fresh `<img>` element never carries over its
1856
+ * predecessor's already-resolved `src`.
1712
1857
  */
1713
- collectPendingImages(roots, animateImageHydration) {
1858
+ collectPendingImages(roots, context, generation, animateImageHydration) {
1714
1859
  if (!this.assetSession) {
1715
1860
  return [];
1716
1861
  }
@@ -1727,6 +1872,22 @@ export class MdzipWorkspaceView {
1727
1872
  }
1728
1873
  continue;
1729
1874
  }
1875
+ const known = this.assetSession.resolveKnownImage(source, context.currentPath);
1876
+ if (known) {
1877
+ if (known.width && known.height && !image.hasAttribute('width') && !image.hasAttribute('height')) {
1878
+ image.setAttribute('width', String(known.width));
1879
+ image.setAttribute('height', String(known.height));
1880
+ }
1881
+ // No slot wrapper on this path — same direct-on-image alignment as
1882
+ // the external-URL branch above, not the slot's own align classes.
1883
+ const alignClass = rawHtmlImageAlignClass(image);
1884
+ if (alignClass) {
1885
+ image.classList.add(alignClass);
1886
+ }
1887
+ this.attachImageLoadHandlers(image, source, known.url, context, generation);
1888
+ image.setAttribute('src', known.url);
1889
+ continue;
1890
+ }
1730
1891
  image.removeAttribute('src');
1731
1892
  if (animateImageHydration) {
1732
1893
  image.classList.add('mdzip-image-loading');
@@ -1896,6 +2057,201 @@ export class MdzipWorkspaceView {
1896
2057
  }
1897
2058
  });
1898
2059
  }
2060
+ /**
2061
+ * Same-document, text-only-edit counterpart to {@link renderChunkedPreview}:
2062
+ * tokenizes the new text, then reconciles against the *previous* render's
2063
+ * records (`priorState`) instead of tearing everything down. Only reachable
2064
+ * when `updatePreview` determined the prior chunked structure is still
2065
+ * reuse-eligible (same path/pathType/colorScheme); the caller already
2066
+ * checked that.
2067
+ */
2068
+ reconcileChunkedPreview(snapshot, context, generation, animateImageHydration, priorState) {
2069
+ let tokens;
2070
+ try {
2071
+ tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
2072
+ }
2073
+ catch (error) {
2074
+ this.options.onFailed?.(error);
2075
+ // Can't reconcile without tokens — fall back to a hard reset.
2076
+ this.destroyPreviewHandles();
2077
+ this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
2078
+ this.firePreviewRendered(snapshot, generation);
2079
+ this.fireAssetsHydrated(snapshot, generation);
2080
+ return;
2081
+ }
2082
+ const proceed = (resolvedTokens) => {
2083
+ if (generation !== this.previewGeneration || context.signal.aborted)
2084
+ return;
2085
+ this.applyChunkReconciliation(resolvedTokens, snapshot, context, generation, animateImageHydration, priorState);
2086
+ };
2087
+ if (Array.isArray(tokens)) {
2088
+ proceed(tokens);
2089
+ return;
2090
+ }
2091
+ void tokens.then(proceed).catch((error) => {
2092
+ if (generation !== this.previewGeneration || context.signal.aborted)
2093
+ return;
2094
+ if (error?.name !== 'AbortError') {
2095
+ this.options.onFailed?.(error);
2096
+ }
2097
+ });
2098
+ }
2099
+ /** Destroys one chunk record's mounted DOM and handles (but not the record object itself). */
2100
+ teardownChunkRecord(record) {
2101
+ for (const handle of record.handles) {
2102
+ try {
2103
+ handle.destroy();
2104
+ }
2105
+ catch (error) {
2106
+ this.options.onFailed?.(error);
2107
+ }
2108
+ }
2109
+ record.root?.remove();
2110
+ }
2111
+ /**
2112
+ * Diffs the new tokenize pass's chunks against `priorState.records` by
2113
+ * source-text identity (`chunkSourceKey`) and reconciles: chunks in the
2114
+ * matched leading/trailing run keep their existing DOM and handles
2115
+ * untouched (never re-rendered, never re-mounted — see
2116
+ * `chunkSourceKey`'s doc comment for why that matters for extensions like
2117
+ * mermaid); only the "middle" range that actually changed is torn down and
2118
+ * remounted. Uses prefix/suffix matching rather than a general LCS — a
2119
+ * text editor's edits are localized, so this is both simpler and correct
2120
+ * for the real-world case.
2121
+ */
2122
+ applyChunkReconciliation(tokens, snapshot, context, generation, animateImageHydration, priorState) {
2123
+ // Tear down any live sentinel from the prior state before touching DOM —
2124
+ // same reasoning as drainRemainingChunks: stops it from racing this
2125
+ // reconciliation and double-mounting a chunk.
2126
+ priorState.sentinelHandle?.destroy();
2127
+ this.previewHandles = this.previewHandles.filter((h) => h !== priorState.sentinelHandle);
2128
+ const newChunks = groupTokensIntoChunks(tokens, this.chunkOptions());
2129
+ const oldRecords = priorState.records;
2130
+ const oldCursor = priorState.cursor;
2131
+ if (newChunks.length === 0) {
2132
+ // Whole document collapsed to nothing renderable — nothing to reuse.
2133
+ for (const record of oldRecords) {
2134
+ this.teardownChunkRecord(record);
2135
+ }
2136
+ this.elPreviewContent.replaceChildren();
2137
+ this.chunkedRenderState = null;
2138
+ this.firePreviewRendered(snapshot, generation);
2139
+ this.fireAssetsHydrated(snapshot, generation);
2140
+ return;
2141
+ }
2142
+ const oldKeys = oldRecords.map((r) => r.key);
2143
+ const newKeys = newChunks.map((c) => chunkSourceKey(c));
2144
+ const maxMatch = Math.min(oldKeys.length, newKeys.length);
2145
+ let prefixLen = 0;
2146
+ while (prefixLen < maxMatch && oldKeys[prefixLen] === newKeys[prefixLen])
2147
+ prefixLen += 1;
2148
+ const maxSuffix = maxMatch - prefixLen;
2149
+ let suffixLen = 0;
2150
+ while (suffixLen < maxSuffix
2151
+ && oldKeys[oldKeys.length - 1 - suffixLen] === newKeys[newKeys.length - 1 - suffixLen])
2152
+ suffixLen += 1;
2153
+ const oldMiddleStart = prefixLen;
2154
+ const oldMiddleEnd = oldRecords.length - suffixLen;
2155
+ const newMiddleStart = prefixLen;
2156
+ const newMiddleEnd = newChunks.length - suffixLen;
2157
+ const newRecords = [];
2158
+ for (let i = 0; i < prefixLen; i += 1) {
2159
+ newRecords.push({ ...oldRecords[i], key: newKeys[i], tokens: newChunks[i] });
2160
+ }
2161
+ for (let i = newMiddleStart; i < newMiddleEnd; i += 1) {
2162
+ newRecords.push({ key: newKeys[i], tokens: newChunks[i], root: null, html: null, handles: [] });
2163
+ }
2164
+ for (let i = 0; i < suffixLen; i += 1) {
2165
+ const oi = oldRecords.length - suffixLen + i;
2166
+ const ni = newChunks.length - suffixLen + i;
2167
+ newRecords.push({ ...oldRecords[oi], key: newKeys[ni], tokens: newChunks[ni] });
2168
+ }
2169
+ // Destroy DOM+handles for old-middle records that were actually mounted
2170
+ // (bounded by the old cursor — everything from there on was still
2171
+ // waiting in the lazy-load tail, never mounted, nothing to destroy).
2172
+ const oldMountedMiddleEnd = Math.min(oldCursor, oldMiddleEnd);
2173
+ for (let i = oldMiddleStart; i < oldMountedMiddleEnd; i += 1) {
2174
+ this.teardownChunkRecord(oldRecords[i]);
2175
+ }
2176
+ const oldMountedMiddleCount = Math.max(0, oldMountedMiddleEnd - oldMiddleStart);
2177
+ // Reference node for splicing in the new middle's DOM: the first
2178
+ // surviving *mounted* suffix record (mounted suffix records are
2179
+ // contiguous from newMiddleEnd by construction), else null (append).
2180
+ let insertBeforeNode = null;
2181
+ for (let i = newMiddleEnd; i < newRecords.length; i += 1) {
2182
+ if (newRecords[i].root) {
2183
+ insertBeforeNode = newRecords[i].root;
2184
+ break;
2185
+ }
2186
+ }
2187
+ this.chunkedRenderState = {
2188
+ records: newRecords,
2189
+ cursor: oldCursor,
2190
+ context,
2191
+ generation,
2192
+ animateImageHydration,
2193
+ sentinelHandle: null,
2194
+ mounting: oldMountedMiddleCount > 0
2195
+ };
2196
+ if (oldMountedMiddleCount === 0) {
2197
+ // The edit is entirely beyond what was mounted (progressive mode,
2198
+ // still in the lazy-load tail) — nothing was destroyed, nothing needs
2199
+ // mounting now. Cursor is unaffected: every index below it is an
2200
+ // untouched prefix survivor, valid at the same index in newRecords. The
2201
+ // old sentinel was already torn down above, so a fresh one has to be
2202
+ // armed to keep the lazy-load tail continuing.
2203
+ if (oldCursor < newRecords.length) {
2204
+ this.armChunkSentinel(newRecords, oldCursor, context, generation, animateImageHydration);
2205
+ }
2206
+ this.firePreviewRendered(snapshot, generation);
2207
+ this.fireAssetsHydrated(snapshot, generation);
2208
+ return;
2209
+ }
2210
+ void this.mountReconciledMiddle(newRecords, newMiddleStart, newMiddleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd);
2211
+ }
2212
+ /**
2213
+ * Mounts a reconciliation's replacement "middle" range in full (uncapped —
2214
+ * a single edit only ever touches 1-2 chunks in practice; see the plan's
2215
+ * disclosed v1 risk for the pathological large-paste case), then carries
2216
+ * forward however much of the surviving suffix was already mounted before
2217
+ * reconciling, and re-arms the sentinel for whatever's still left.
2218
+ */
2219
+ async mountReconciledMiddle(records, middleStart, middleEnd, insertBeforeNode, context, generation, animateImageHydration, snapshot, oldCursor, oldMiddleEnd) {
2220
+ let cursor = middleStart;
2221
+ let mountedRoots = [];
2222
+ while (cursor < middleEnd) {
2223
+ const batch = await this.renderAndMountChunkBatch(records, cursor, context, generation, insertBeforeNode);
2224
+ if (generation !== this.previewGeneration || context.signal.aborted)
2225
+ return;
2226
+ if (batch.cursor === cursor)
2227
+ break; // Aborted/superseded mid-loop with no progress.
2228
+ cursor = batch.cursor;
2229
+ mountedRoots = mountedRoots.concat(batch.mountedRoots);
2230
+ }
2231
+ const carriedSuffixMounted = Math.max(0, oldCursor - oldMiddleEnd);
2232
+ const finalCursor = Math.min(records.length, Math.max(cursor, middleEnd) + carriedSuffixMounted);
2233
+ // See armChunkSentinel's mountNext for why this is recorded before the
2234
+ // staleness check: finalCursor already correctly folds in the carried-
2235
+ // forward suffix (chunks beyond the reconciled middle that were already
2236
+ // mounted), so discarding it here just because a newer edit has since
2237
+ // landed would regress chunkedRenderState.cursor back down — causing the
2238
+ // next reconcile to re-arm a sentinel from that stale, lower cursor and
2239
+ // re-mount (duplicate) chunks that are already correctly in the DOM.
2240
+ this.recordChunkProgress(generation, records, finalCursor);
2241
+ if (generation !== this.previewGeneration || context.signal.aborted)
2242
+ return;
2243
+ if (this.chunkedRenderState?.generation === generation) {
2244
+ this.chunkedRenderState.mounting = false;
2245
+ }
2246
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2247
+ this.hydrateImages(pending, context, generation, animateImageHydration, () => {
2248
+ this.fireAssetsHydrated(snapshot, generation);
2249
+ });
2250
+ this.firePreviewRendered(snapshot, generation);
2251
+ if (this.chunkedRenderState?.generation === generation && finalCursor < records.length) {
2252
+ this.armChunkSentinel(records, finalCursor, context, generation, animateImageHydration);
2253
+ }
2254
+ }
1899
2255
  /**
1900
2256
  * Groups tokens into chunks and mounts them: an initial batch synchronously
1901
2257
  * (enough for a small document to behave exactly like the non-chunked
@@ -1911,41 +2267,111 @@ export class MdzipWorkspaceView {
1911
2267
  this.elPreviewContent.replaceChildren();
1912
2268
  // Also called with no options in renderFullDocumentHtml — see the
1913
2269
  // comment there; keep both call sites' chunking in sync.
1914
- const chunks = groupTokensIntoChunks(tokens);
2270
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
1915
2271
  if (chunks.length === 0) {
1916
2272
  this.chunkedRenderState = null;
1917
2273
  this.firePreviewRendered(snapshot, generation);
1918
2274
  this.fireAssetsHydrated(snapshot, generation);
1919
2275
  return;
1920
2276
  }
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) => {
2277
+ const records = chunks.map((chunkTokens) => ({
2278
+ key: chunkSourceKey(chunkTokens),
2279
+ tokens: chunkTokens,
2280
+ root: null,
2281
+ html: null,
2282
+ handles: []
2283
+ }));
2284
+ this.chunkedRenderState = {
2285
+ records, cursor: 0, context, generation, animateImageHydration, sentinelHandle: null,
2286
+ // Set for the whole initial batch/eager mount below — see the field's
2287
+ // doc comment: an edit landing before this settles must not reconcile
2288
+ // against a `cursor` that doesn't yet reflect this in-flight mount.
2289
+ mounting: true
2290
+ };
2291
+ if (this.progressiveTextRendering) {
2292
+ // Mount an initial batch synchronously (enough for a small document to
2293
+ // behave exactly like a non-progressive mount), then lazily continue
2294
+ // the rest as the user scrolls near a trailing sentinel.
2295
+ void this.mountChunkBatch(records, 0, context, generation, animateImageHydration, () => {
2296
+ this.fireAssetsHydrated(snapshot, generation);
2297
+ }).then((cursor) => {
2298
+ // See armChunkSentinel's mountNext for why this is recorded before
2299
+ // the staleness check below.
2300
+ this.recordChunkProgress(generation, records, cursor);
2301
+ if (generation !== this.previewGeneration || context.signal.aborted)
2302
+ return;
2303
+ if (this.chunkedRenderState?.generation === generation) {
2304
+ this.chunkedRenderState.mounting = false;
2305
+ }
2306
+ this.firePreviewRendered(snapshot, generation);
2307
+ if (cursor < records.length) {
2308
+ this.armChunkSentinel(records, cursor, context, generation, animateImageHydration);
2309
+ }
2310
+ });
2311
+ return;
2312
+ }
2313
+ // Not opted into lazy-load-on-scroll: mount every chunk up front, same
2314
+ // visible end state as the old monolithic non-chunked path, but yielding
2315
+ // a frame between batches (mirroring drainRemainingChunks) instead of
2316
+ // blocking the main thread for one giant synchronous render — see the
2317
+ // plan's disclosed cold-start tradeoff.
2318
+ void this.mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot);
2319
+ }
2320
+ /**
2321
+ * Mounts every chunk of a non-progressive (`progressiveTextRendering:
2322
+ * false`) document up front — same end state as the old monolithic
2323
+ * non-chunked path (the whole document mounted before `onPreviewRendered`
2324
+ * fires), but yielding a frame between batches (mirroring
2325
+ * `drainRemainingChunks`) instead of blocking the main thread for one
2326
+ * giant synchronous render.
2327
+ */
2328
+ async mountAllChunksEagerly(records, context, generation, animateImageHydration, snapshot) {
2329
+ const allPending = [];
2330
+ while (generation === this.previewGeneration && !context.signal.aborted) {
2331
+ const cursorBefore = this.chunkedRenderState?.generation === generation
2332
+ ? this.chunkedRenderState.cursor
2333
+ : records.length;
2334
+ if (cursorBefore >= records.length)
2335
+ break;
2336
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursorBefore, context, generation);
2337
+ // See armChunkSentinel's mountNext for why this is recorded before the
2338
+ // staleness check: the chunks this batch appended are physically in
2339
+ // the DOM regardless of whether a newer edit has since landed, and
2340
+ // recordChunkProgress's own generation check keeps this a safe no-op
2341
+ // once chunkedRenderState has moved on.
2342
+ this.recordChunkProgress(generation, records, cursor);
1925
2343
  if (generation !== this.previewGeneration || context.signal.aborted)
1926
2344
  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);
2345
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
2346
+ if (cursor >= records.length)
2347
+ break;
2348
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
2349
+ }
2350
+ if (generation === this.previewGeneration && !context.signal.aborted) {
2351
+ if (this.chunkedRenderState?.generation === generation) {
2352
+ this.chunkedRenderState.mounting = false;
1931
2353
  }
1932
- });
2354
+ this.firePreviewRendered(snapshot, generation);
2355
+ this.hydrateImages(allPending, context, generation, animateImageHydration, () => {
2356
+ this.fireAssetsHydrated(snapshot, generation);
2357
+ });
2358
+ }
1933
2359
  }
1934
2360
  /**
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.
2361
+ * Updates `chunkedRenderState.cursor` after a batch mounts. A no-op if a
2362
+ * newer render generation has already superseded this one. Deliberately
2363
+ * never nulls `chunkedRenderState` out just because `cursor` reached
2364
+ * `records.length` — the records stay alive for the whole generation so a
2365
+ * later same-document edit can reconcile against them (see
2366
+ * `applyChunkReconciliation`). Callers that need "is there still an
2367
+ * unmounted lazy tail" (Copy All's instant path, the scroll-to-bottom fast
2368
+ * path) check `cursor < records.length` directly instead of this field's
2369
+ * truthiness.
1939
2370
  */
1940
- recordChunkProgress(generation, chunks, cursor) {
2371
+ recordChunkProgress(generation, records, cursor) {
1941
2372
  if (this.chunkedRenderState?.generation !== generation)
1942
2373
  return;
1943
- if (cursor >= chunks.length) {
1944
- this.chunkedRenderState = null;
1945
- }
1946
- else {
1947
- this.chunkedRenderState.cursor = cursor;
1948
- }
2374
+ this.chunkedRenderState.cursor = cursor;
1949
2375
  }
1950
2376
  /**
1951
2377
  * Renders+appends chunks starting at `startCursor` up to a char budget
@@ -1964,7 +2390,7 @@ export class MdzipWorkspaceView {
1964
2390
  * included here; see {@link mountChunkBatch} and
1965
2391
  * {@link drainRemainingChunks} for the two different ways callers pace it.
1966
2392
  */
1967
- async renderAndMountChunkBatch(chunks, startCursor, context, generation) {
2393
+ async renderAndMountChunkBatch(records, startCursor, context, generation, insertBeforeNode = null) {
1968
2394
  const BATCH_CHAR_BUDGET = 4000;
1969
2395
  const BATCH_TIME_BUDGET_MS = 10;
1970
2396
  const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
@@ -1972,12 +2398,13 @@ export class MdzipWorkspaceView {
1972
2398
  let cursor = startCursor;
1973
2399
  let renderedChars = 0;
1974
2400
  const mountedRoots = [];
1975
- while (cursor < chunks.length
2401
+ const batchStartCursor = startCursor;
2402
+ while (cursor < records.length
1976
2403
  && renderedChars < BATCH_CHAR_BUDGET
1977
2404
  && (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
1978
2405
  if (generation !== this.previewGeneration || context.signal.aborted)
1979
2406
  return { cursor, mountedRoots };
1980
- const chunkTokens = chunks[cursor];
2407
+ const chunkTokens = records[cursor].tokens;
1981
2408
  const chunkIndex = cursor;
1982
2409
  cursor += 1;
1983
2410
  let html;
@@ -2011,15 +2438,19 @@ export class MdzipWorkspaceView {
2011
2438
  }
2012
2439
  this.chunkHtmlCache.html.set(chunkIndex, html);
2013
2440
  }
2014
- const root = this.appendChunkHtml(html);
2441
+ const root = this.appendChunkHtml(html, insertBeforeNode);
2442
+ records[chunkIndex].root = root;
2443
+ records[chunkIndex].html = html;
2015
2444
  mountedRoots.push(root);
2016
2445
  renderedChars += html.length;
2017
2446
  }
2018
- for (const root of mountedRoots) {
2019
- this.mountPreviewExtensions(context, generation, root);
2020
- const codeBlockHandle = this.mountCodeBlockControls(root);
2447
+ for (const record of records.slice(batchStartCursor, cursor)) {
2448
+ if (!record.root)
2449
+ continue;
2450
+ this.mountPreviewExtensions(context, generation, record.root, record.handles);
2451
+ const codeBlockHandle = this.mountCodeBlockControls(record.root);
2021
2452
  if (codeBlockHandle) {
2022
- this.previewHandles.push(codeBlockHandle);
2453
+ record.handles.push(codeBlockHandle);
2023
2454
  }
2024
2455
  }
2025
2456
  return { cursor, mountedRoots };
@@ -2033,21 +2464,31 @@ export class MdzipWorkspaceView {
2033
2464
  * by how fast the user scrolls, so one independent hydration loop per
2034
2465
  * batch never has a chance to pile up against another.
2035
2466
  */
2036
- async mountChunkBatch(chunks, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2037
- const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, startCursor, context, generation);
2467
+ async mountChunkBatch(records, startCursor, context, generation, animateImageHydration, onImagesSettled) {
2468
+ const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, startCursor, context, generation);
2038
2469
  // Same relative order as the non-chunked path: cheap image pass, then
2039
2470
  // the (expensive) slot/observe pass.
2040
- const pending = this.collectPendingImages(mountedRoots, animateImageHydration);
2471
+ const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
2041
2472
  this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
2042
2473
  return cursor;
2043
2474
  }
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) {
2475
+ /**
2476
+ * Wraps one chunk's rendered HTML in a mount boundary and appends it (or,
2477
+ * given `beforeNode`, splices it in ahead of that still-attached node —
2478
+ * used by reconciliation to insert a replacement chunk ahead of a
2479
+ * surviving mounted suffix). See the `.mdzip-chunk` CSS rules for why.
2480
+ */
2481
+ appendChunkHtml(html, beforeNode = null) {
2046
2482
  const doc = this.elPreviewContent.ownerDocument;
2047
2483
  const wrapper = doc.createElement('div');
2048
2484
  wrapper.className = 'mdzip-chunk';
2049
2485
  wrapper.innerHTML = html;
2050
- this.elPreviewContent.appendChild(wrapper);
2486
+ if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
2487
+ this.elPreviewContent.insertBefore(wrapper, beforeNode);
2488
+ }
2489
+ else {
2490
+ this.elPreviewContent.appendChild(wrapper);
2491
+ }
2051
2492
  return wrapper;
2052
2493
  }
2053
2494
  /**
@@ -2058,17 +2499,36 @@ export class MdzipWorkspaceView {
2058
2499
  * every chunk is mounted. Falls back to mounting everything immediately
2059
2500
  * when IntersectionObserver isn't available, matching `hydrateImages`.
2060
2501
  */
2061
- armChunkSentinel(chunks, cursor, context, generation, animateImageHydration) {
2502
+ armChunkSentinel(records, cursor, context, generation, animateImageHydration) {
2062
2503
  const doc = this.elPreviewContent.ownerDocument;
2063
2504
  const observerWindow = doc.defaultView;
2064
2505
  const mountNext = (nextCursor) => {
2065
- void this.mountChunkBatch(chunks, nextCursor, context, generation, animateImageHydration, () => { })
2506
+ // Same race this field guards against everywhere else: an edit landing
2507
+ // while this scroll-triggered continuation is mid-flight must not
2508
+ // reconcile against a cursor that doesn't yet reflect it.
2509
+ if (this.chunkedRenderState?.generation === generation) {
2510
+ this.chunkedRenderState.mounting = true;
2511
+ }
2512
+ void this.mountChunkBatch(records, nextCursor, context, generation, animateImageHydration, () => { })
2066
2513
  .then((newCursor) => {
2514
+ // Record whatever this batch actually mounted regardless of
2515
+ // whether a newer edit has since landed — recordChunkProgress's
2516
+ // own generation check already makes this a safe no-op once
2517
+ // chunkedRenderState has moved on. Skipping this call when stale
2518
+ // was a real bug: the chunks this batch appended are physically in
2519
+ // the DOM either way, but a newer edit's reconcile still saw the
2520
+ // *old*, un-advanced cursor and re-armed a sentinel from the same
2521
+ // starting point — re-rendering and re-appending (duplicating)
2522
+ // chunks that were already mounted, visible as an already-settled
2523
+ // image flashing back to broken as a fresh duplicate replaced it.
2524
+ this.recordChunkProgress(generation, records, newCursor);
2067
2525
  if (generation !== this.previewGeneration || context.signal.aborted)
2068
2526
  return;
2069
- this.recordChunkProgress(generation, chunks, newCursor);
2070
- if (newCursor < chunks.length) {
2071
- this.armChunkSentinel(chunks, newCursor, context, generation, animateImageHydration);
2527
+ if (this.chunkedRenderState?.generation === generation) {
2528
+ this.chunkedRenderState.mounting = false;
2529
+ }
2530
+ if (newCursor < records.length) {
2531
+ this.armChunkSentinel(records, newCursor, context, generation, animateImageHydration);
2072
2532
  }
2073
2533
  });
2074
2534
  };
@@ -2169,7 +2629,7 @@ export class MdzipWorkspaceView {
2169
2629
  * appended chunk rather than once over the whole document. Defaults to
2170
2630
  * the whole preview content, matching the non-chunked path exactly.
2171
2631
  */
2172
- mountPreviewExtensions(context, generation, root = this.elPreviewContent) {
2632
+ mountPreviewExtensions(context, generation, root = this.elPreviewContent, collector = this.previewHandles) {
2173
2633
  for (const extension of this.markdownExtensions) {
2174
2634
  if (!extension.mount) {
2175
2635
  continue;
@@ -2190,7 +2650,7 @@ export class MdzipWorkspaceView {
2190
2650
  }
2191
2651
  return;
2192
2652
  }
2193
- this.previewHandles.push(handle);
2653
+ collector.push(handle);
2194
2654
  }).catch((error) => {
2195
2655
  if (generation === this.previewGeneration) {
2196
2656
  this.options.onFailed?.(error);
@@ -2198,7 +2658,7 @@ export class MdzipWorkspaceView {
2198
2658
  });
2199
2659
  }
2200
2660
  else if (mounted) {
2201
- this.previewHandles.push(mounted);
2661
+ collector.push(mounted);
2202
2662
  }
2203
2663
  }
2204
2664
  catch (error) {
@@ -2337,6 +2797,7 @@ export class MdzipWorkspaceView {
2337
2797
  if (generation !== this.previewGeneration) {
2338
2798
  return;
2339
2799
  }
2800
+ this.restorePendingPreviewScroll(generation);
2340
2801
  try {
2341
2802
  this.options.onPreviewRendered?.(snapshot);
2342
2803
  }
@@ -2344,6 +2805,20 @@ export class MdzipWorkspaceView {
2344
2805
  this.options.onFailed?.(error);
2345
2806
  }
2346
2807
  }
2808
+ /** See `pendingPreviewScrollRestore`'s doc comment. */
2809
+ restorePendingPreviewScroll(generation) {
2810
+ const pending = this.pendingPreviewScrollRestore;
2811
+ if (!pending || pending.generation !== generation) {
2812
+ return;
2813
+ }
2814
+ this.pendingPreviewScrollRestore = null;
2815
+ if (this.elPreviewPane.scrollTop !== pending.scrollTop) {
2816
+ this.syncing = true;
2817
+ this.lastSyncedPreviewScrollTop = pending.scrollTop;
2818
+ this.elPreviewPane.scrollTop = pending.scrollTop;
2819
+ this.syncing = false;
2820
+ }
2821
+ }
2347
2822
  fireAssetsHydrated(snapshot, generation) {
2348
2823
  if (generation !== this.previewGeneration) {
2349
2824
  return;
@@ -2648,6 +3123,19 @@ export class MdzipWorkspaceView {
2648
3123
  // Attach scroll listener to sync with preview
2649
3124
  const scroller = editor.dom.querySelector('.cm-scroller');
2650
3125
  if (scroller) {
3126
+ // No 'keydown' here, deliberately: .cm-scroller receives every keystroke
3127
+ // typed into the editor, not just navigation keys, so it can't tell
3128
+ // "user pressed PageDown to scroll" apart from "user typed a letter" —
3129
+ // confirmed live, where ordinary typing was arming this gesture window
3130
+ // and letting CodeMirror's own small post-edit viewport-correction
3131
+ // scroll (a real native behavior) through as if it were a deliberate
3132
+ // scroll, on every keystroke. Wheel/touch/mousedown (which also covers
3133
+ // a scrollbar-thumb drag, since that lands on this same element) are
3134
+ // unambiguous and cover the vast majority of real scrolling.
3135
+ const markEditorGesture = () => { self.lastEditorGestureTime = performance.now(); };
3136
+ scroller.addEventListener('wheel', markEditorGesture, { passive: true });
3137
+ scroller.addEventListener('touchstart', markEditorGesture, { passive: true });
3138
+ scroller.addEventListener('mousedown', markEditorGesture);
2651
3139
  scroller.addEventListener('scroll', () => self.syncScrollToPreview());
2652
3140
  }
2653
3141
  editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
@@ -3421,6 +3909,11 @@ export class MdzipWorkspaceView {
3421
3909
  void this.replaceFileFromPicker(path, file);
3422
3910
  }
3423
3911
  });
3912
+ const markPreviewGesture = () => { this.lastPreviewGestureTime = performance.now(); };
3913
+ this.elPreviewPane.addEventListener('wheel', markPreviewGesture, { passive: true });
3914
+ this.elPreviewPane.addEventListener('touchstart', markPreviewGesture, { passive: true });
3915
+ this.elPreviewPane.addEventListener('mousedown', markPreviewGesture);
3916
+ this.elPreviewPane.addEventListener('keydown', markPreviewGesture);
3424
3917
  this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
3425
3918
  // Gives the pane logical focus on click so a subsequent Ctrl/Cmd+A (below)
3426
3919
  // can be scoped to it. tabindex="-1" keeps it out of Tab order; this is
@@ -4460,20 +4953,22 @@ export class MdzipWorkspaceView {
4460
4953
  * Copies the entire rendered document as plain text (same fidelity as
4461
4954
  * `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
4462
4955
  * 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.
4956
+ * everything's already mounted (small doc, eager/non-progressive mount, or
4957
+ * the user already scrolled through it) this is instant —
4958
+ * `chunkedRenderState` is either null (no markdown open, or a non-default
4959
+ * renderer) or has nothing left to mount (`cursor >= records.length`) in
4960
+ * exactly that case. Otherwise it force-drains the rest first, showing a
4961
+ * cancelable progress dialog once the wait clears a short debounce so fast
4962
+ * documents never flicker it into view.
4468
4963
  */
4469
4964
  async copyAllPreviewContent() {
4470
4965
  const pending = this.chunkedRenderState;
4471
- if (!pending) {
4966
+ if (!pending || pending.cursor >= pending.records.length) {
4472
4967
  const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
4473
4968
  this.finishCopyNotification(false, outcome);
4474
4969
  return;
4475
4970
  }
4476
- const total = pending.chunks.length;
4971
+ const total = pending.records.length;
4477
4972
  const abort = new AbortController();
4478
4973
  let dialogShown = false;
4479
4974
  const label = 'Rendering the full document';
@@ -4503,8 +4998,9 @@ export class MdzipWorkspaceView {
4503
4998
  // Cancelled partway through — whatever's left stays unmounted, so
4504
4999
  // re-arm the usual scroll-driven continuation for it.
4505
5000
  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);
5001
+ if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle
5002
+ && remaining.cursor < remaining.records.length) {
5003
+ this.armChunkSentinel(remaining.records, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
4508
5004
  }
4509
5005
  return;
4510
5006
  }
@@ -4546,7 +5042,7 @@ export class MdzipWorkspaceView {
4546
5042
  // renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
4547
5043
  // call sites' chunking in sync, since chunk index is chunkHtmlCache's
4548
5044
  // only key.
4549
- const chunks = groupTokensIntoChunks(tokens);
5045
+ const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
4550
5046
  const view = this.elPreviewContent.ownerDocument.defaultView;
4551
5047
  const clock = view?.performance ?? performance;
4552
5048
  const BATCH_TIME_BUDGET_MS = 10;
@@ -4761,32 +5257,39 @@ export class MdzipWorkspaceView {
4761
5257
  * making Copy All wait on it.
4762
5258
  */
4763
5259
  async drainRemainingChunks(state, onProgress, signal) {
4764
- const { chunks, context, generation, animateImageHydration } = state;
5260
+ const { records, context, generation, animateImageHydration } = state;
4765
5261
  // Draining is instead of the scroll-driven continuation, not alongside
4766
5262
  // it — tearing down any armed sentinel first stops the two from racing
4767
5263
  // and double-mounting the same chunk.
4768
5264
  state.sentinelHandle?.destroy();
4769
5265
  if (this.chunkedRenderState?.generation === generation) {
4770
5266
  this.chunkedRenderState.sentinelHandle = null;
5267
+ // Same race the `mounting` field guards against elsewhere: an edit
5268
+ // landing mid-drain must not reconcile against a cursor this loop is
5269
+ // actively advancing out from under it.
5270
+ this.chunkedRenderState.mounting = true;
4771
5271
  }
4772
5272
  const allPending = [];
4773
5273
  try {
4774
- while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < chunks.length) {
5274
+ while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < records.length) {
4775
5275
  if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
4776
5276
  return;
4777
5277
  const cursor = this.chunkedRenderState.cursor;
4778
- const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, cursor, context, generation);
5278
+ const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursor, context, generation);
4779
5279
  if (generation !== this.previewGeneration || context.signal.aborted)
4780
5280
  return;
4781
- allPending.push(...this.collectPendingImages(mountedRoots, animateImageHydration));
4782
- this.recordChunkProgress(generation, chunks, newCursor);
4783
- onProgress(newCursor, chunks.length);
5281
+ allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
5282
+ this.recordChunkProgress(generation, records, newCursor);
5283
+ onProgress(newCursor, records.length);
4784
5284
  if (signal.aborted)
4785
5285
  return;
4786
5286
  await new Promise((resolve) => requestAnimationFrame(() => resolve()));
4787
5287
  }
4788
5288
  }
4789
5289
  finally {
5290
+ if (this.chunkedRenderState?.generation === generation) {
5291
+ this.chunkedRenderState.mounting = false;
5292
+ }
4790
5293
  if (generation === this.previewGeneration && !context.signal.aborted) {
4791
5294
  this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
4792
5295
  }
@@ -5757,6 +6260,12 @@ export class MdzipWorkspaceView {
5757
6260
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
5758
6261
  return;
5759
6262
  }
6263
+ if (performance.now() - this.lastPreviewGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6264
+ return;
6265
+ }
6266
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6267
+ return;
6268
+ }
5760
6269
  const currentTop = this.elPreviewPane.scrollTop;
5761
6270
  // Recognize this event as the echo of our own prior write (from
5762
6271
  // syncScrollToPreview) by comparing values instead of racing timing.
@@ -5787,6 +6296,12 @@ export class MdzipWorkspaceView {
5787
6296
  if (this.syncing || !this.cmEditor || this.layout !== 'split') {
5788
6297
  return;
5789
6298
  }
6299
+ if (performance.now() - this.lastEditorGestureTime > MdzipWorkspaceView.SCROLL_SYNC_GESTURE_WINDOW_MS) {
6300
+ return;
6301
+ }
6302
+ if (performance.now() - this.lastContentChangeTime < MdzipWorkspaceView.SCROLL_SYNC_SETTLE_MS) {
6303
+ return;
6304
+ }
5790
6305
  const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
5791
6306
  if (!cmScroller) {
5792
6307
  return;
@@ -5813,8 +6328,23 @@ export class MdzipWorkspaceView {
5813
6328
  // CodeMirror has actually decided to draw for the current scroll
5814
6329
  // position, so comparing it to the document length is exact regardless
5815
6330
  // of any height estimation drift.
5816
- const atDocEnd = this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
5817
- if (atDocEnd && this.chunkedRenderState) {
6331
+ //
6332
+ // `viewport.to >= doc.length` alone is a false positive whenever the
6333
+ // whole document already fits within the editor's visible area — there
6334
+ // is no scrolling at all in that case, so `viewport.to` trivially covers
6335
+ // the full document from position 0 regardless of scroll intent. Without
6336
+ // the overflow check below, a short-to-medium document (especially one
6337
+ // with images, which slow down per-chunk mounting enough to leave a real
6338
+ // window where `cursor < records.length`) could hit this branch off of
6339
+ // an incidental scroll event fired mid-edit — e.g. focus or cursor
6340
+ // movement nudging `scrollTop` by a sub-pixel amount — and force-drain
6341
+ // plus jump the preview to its bottom for no reason the user asked for,
6342
+ // which (via the echo-breaking reflow that follows, since late-arriving
6343
+ // image layout shifts the "bottom" after the jump) can drag the editor's
6344
+ // own scroll down to match through `syncScrollFromPreview`'s echo path.
6345
+ const hasScrollableOverflow = editorHeight > 0;
6346
+ const atDocEnd = hasScrollableOverflow && this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
6347
+ if (atDocEnd && this.chunkedRenderState && this.chunkedRenderState.cursor < this.chunkedRenderState.records.length) {
5818
6348
  void this.syncScrollToPreviewBottom();
5819
6349
  return;
5820
6350
  }
@@ -5857,7 +6387,7 @@ export class MdzipWorkspaceView {
5857
6387
  }
5858
6388
  let showToastTimer = setTimeout(() => {
5859
6389
  showToastTimer = null;
5860
- this.scrollCatchUpState = { done: state.cursor, total: state.chunks.length };
6390
+ this.scrollCatchUpState = { done: state.cursor, total: state.records.length };
5861
6391
  this.updateScrollCatchUpToast();
5862
6392
  }, 200);
5863
6393
  try {
@@ -6057,325 +6587,325 @@ function formatMetadataValue(value) {
6057
6587
  }
6058
6588
  return 'Not available';
6059
6589
  }
6060
- const SHELL_HTML = `
6061
- <section class="mdzip-root">
6062
- <div class="document-strip" data-ref="document-strip" hidden>
6063
- <button type="button" class="title-button" data-ref="title-btn"></button>
6064
- <button type="button" class="document-info-button" data-ref="document-info-btn"
6065
- title="Document information" aria-label="Document information">
6066
- ${INFO_ICON_HTML}
6067
- </button>
6068
- </div>
6069
-
6070
- <header class="toolbar" data-ref="toolbar" hidden>
6071
- <div class="toolbar-start">
6072
- <div class="toolbar-left">
6073
- <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
6074
- ${NAV_TOGGLE_ICON_HTML}
6075
- </button>
6076
- </div>
6077
-
6078
- <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
6079
- <div class="edit-toolbar-group">
6080
- <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
6081
- <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
6082
- <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
6083
- <div class="format-menu" data-format-control="headings">
6084
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
6085
- aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
6086
- ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
6087
- </button>
6088
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
6089
- <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
6090
- <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
6091
- <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
6092
- <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
6093
- <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
6094
- <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
6095
- <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
6096
- </div>
6097
- </div>
6098
- </div>
6099
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
6100
- <div class="edit-toolbar-group">
6101
- <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
6102
- <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
6103
- <div class="format-menu" data-format-control="code">
6104
- <button type="button" class="format-menu-toggle" data-format-menu-toggle
6105
- aria-label="Code" aria-haspopup="menu" aria-expanded="false">
6106
- ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
6107
- </button>
6108
- <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
6109
- <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
6110
- <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
6111
- </div>
6112
- </div>
6113
- <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
6114
- <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
6115
- </div>
6116
- <span class="edit-toolbar-divider" aria-hidden="true"></span>
6117
- <div class="edit-toolbar-group">
6118
- <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
6119
- <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
6120
- <input type="file" data-ref="image-input" accept="image/*" hidden />
6121
- </div>
6122
- </div>
6123
- </div>
6124
-
6125
- <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
6126
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
6127
- <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
6128
- </button>
6129
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
6130
- <span class="commandbar-flex-container">
6131
- ${SPLIT_ICON_HTML}
6132
- </span>
6133
- </button>
6134
- <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
6135
- <span class="commandbar-flex-container">
6136
- ${PREVIEW_ICON_HTML}
6137
- </span>
6138
- </button>
6139
- </div>
6140
-
6141
- <div class="toolbar-controls" data-ref="toolbar-controls">
6142
- <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
6143
- ${SEARCH_ICON_HTML}
6144
- </button>
6145
- <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
6146
- ${SAVE_ICON_HTML}
6147
- </button>
6148
- <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
6149
- ${ZOOM_ICON_HTML}
6150
- </button>
6151
- <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
6152
- <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
6153
- title="Dark mode" aria-label="Dark mode" aria-pressed="false">
6154
- ${DARK_THEME_ICON_HTML}
6155
- </button>
6156
- <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
6157
- title="Light mode" aria-label="Light mode" aria-pressed="false">
6158
- ${LIGHT_THEME_ICON_HTML}
6159
- </button>
6160
- </div>
6161
- <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
6162
- <span class="zoom-level" data-ref="zoom-level">100%</span>
6163
- <span class="zoom-stepper">
6164
- <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
6165
- <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
6166
- </span>
6167
- <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
6168
- </div>
6169
- </div>
6170
- </header>
6171
-
6172
- <div class="workspace-shell" data-ref="workspace-shell" hidden>
6173
- <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
6174
- <div class="nav-tree" data-ref="nav-tree"></div>
6175
- </aside>
6176
- <div class="nav-resizer" data-ref="nav-resizer"
6177
- role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
6178
-
6179
- <div class="pane-stack" data-ref="pane-stack">
6180
- <section class="pane edit-pane" data-ref="edit-pane">
6181
- <div class="editor-host" data-ref="editor-host"></div>
6182
- </section>
6183
- <div class="split-resizer" data-ref="split-resizer"
6184
- role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
6185
- <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
6186
- <article class="preview-content" data-ref="preview-content"></article>
6187
- </section>
6188
- <section class="pane entry-pane" data-ref="entry-pane"></section>
6189
- </div>
6190
- </div>
6191
-
6192
- <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
6193
- role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
6194
- <div class="title-dialog">
6195
- <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
6196
- <p>This is the package-level title stored in manifest.json.</p>
6197
- <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
6198
- <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
6199
- <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
6200
- <div class="title-dialog-actions">
6201
- <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
6202
- <button type="button" data-action="cancel-title">Cancel</button>
6203
- <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
6204
- </div>
6205
- </div>
6206
- </div>
6207
-
6208
- <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
6209
- role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
6210
- <div class="title-dialog">
6211
- <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
6212
- <p>
6213
- Regular Markdown files contain only text. Convert this document to an
6214
- MDZ package to add images, package navigation, and document metadata.
6215
- </p>
6216
- <p>The next save will produce an .mdz file.</p>
6217
- <div class="title-dialog-actions">
6218
- <button type="button" data-action="cancel-conversion">Cancel</button>
6219
- <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
6220
- </div>
6221
- </div>
6222
- </div>
6223
-
6224
- <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
6225
- role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
6226
- <div class="title-dialog image-insert-dialog">
6227
- <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
6228
- <p>Choose the Markdown inserted for this image.</p>
6229
- <fieldset class="image-insert-options">
6230
- <legend>Output</legend>
6231
- <label>
6232
- <input type="radio" name="mdzip-image-insert-mode" value="markdown"
6233
- data-ref="image-insert-mode-markdown" checked />
6234
- Markdown image
6235
- </label>
6236
- <label>
6237
- <input type="radio" name="mdzip-image-insert-mode" value="html"
6238
- data-ref="image-insert-mode-html" />
6239
- HTML &lt;img&gt; with sizing
6240
- </label>
6241
- </fieldset>
6242
- <label class="image-insert-field">
6243
- <span>Alt text</span>
6244
- <input type="text" data-ref="image-insert-alt" />
6245
- </label>
6246
- <div class="image-insert-grid">
6247
- <label class="image-insert-field">
6248
- <span>Size by</span>
6249
- <select data-ref="image-insert-size-mode">
6250
- <option value="original">Original size</option>
6251
- <option value="width" selected>Width</option>
6252
- <option value="height">Height</option>
6253
- <option value="percent">Percent</option>
6254
- </select>
6255
- </label>
6256
- <label class="image-insert-field">
6257
- <span>Value</span>
6258
- <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
6259
- </label>
6260
- </div>
6261
- <label class="image-insert-field">
6262
- <span>Position</span>
6263
- <select data-ref="image-insert-position">
6264
- <option value="inline">Inline/default</option>
6265
- <option value="left">Left</option>
6266
- <option value="center">Center</option>
6267
- <option value="right">Right</option>
6268
- <option value="wrap-left">Wrap left</option>
6269
- <option value="wrap-right">Wrap right</option>
6270
- </select>
6271
- </label>
6272
- <div class="title-dialog-actions">
6273
- <button type="button" data-action="cancel-image-insert">Cancel</button>
6274
- <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
6275
- </div>
6276
- </div>
6277
- </div>
6278
-
6279
- <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
6280
- role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
6281
- <div class="title-dialog pack-files-dialog">
6282
- <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
6283
- <p>These files contain more than one Markdown document. Choose how to package them.</p>
6284
- <fieldset class="pack-files-options">
6285
- <legend>Mode</legend>
6286
- <label>
6287
- <input type="radio" name="mdzip-pack-files-mode" value="document"
6288
- data-ref="pack-files-mode-document" checked />
6289
- Document — one primary page; the rest are attached
6290
- </label>
6291
- <label>
6292
- <input type="radio" name="mdzip-pack-files-mode" value="project"
6293
- data-ref="pack-files-mode-project" />
6294
- Project — a set of related documents
6295
- </label>
6296
- </fieldset>
6297
- <label class="pack-files-field">
6298
- <span>Entry point</span>
6299
- <select data-ref="pack-files-entry"></select>
6300
- </label>
6301
- <div class="title-dialog-actions">
6302
- <button type="button" data-action="cancel-pack-files">Cancel</button>
6303
- <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
6304
- </div>
6305
- </div>
6306
- </div>
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
-
6332
- <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
6333
- role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
6334
- <div class="title-dialog metadata-dialog">
6335
- <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
6336
- <dl data-ref="metadata-list"></dl>
6337
- <h4>Libraries</h4>
6338
- <dl data-ref="library-list"></dl>
6339
- <div class="title-dialog-actions">
6340
- <button type="button" class="save-title" data-action="close-metadata">Close</button>
6341
- </div>
6342
- </div>
6343
- </div>
6344
-
6345
- <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
6346
- role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
6347
- <div class="title-dialog">
6348
- <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
6349
- <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
6350
- <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
6351
- <div class="title-dialog-actions">
6352
- <button type="button" data-action="cancel-name">Cancel</button>
6353
- <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
6354
- </div>
6355
- </div>
6356
- </div>
6357
-
6358
- <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
6359
- role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
6360
- <div class="title-dialog">
6361
- <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
6362
- <p data-ref="delete-dialog-text"></p>
6363
- <div class="title-dialog-actions">
6364
- <button type="button" data-action="cancel-delete">Cancel</button>
6365
- <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
6366
- </div>
6367
- </div>
6368
- </div>
6369
-
6370
- <input type="file" data-ref="replace-input" hidden />
6371
-
6372
- <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
6373
-
6374
- <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
6375
-
6376
- <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
6377
-
6378
- <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
6379
- </section>
6590
+ const SHELL_HTML = `
6591
+ <section class="mdzip-root">
6592
+ <div class="document-strip" data-ref="document-strip" hidden>
6593
+ <button type="button" class="title-button" data-ref="title-btn"></button>
6594
+ <button type="button" class="document-info-button" data-ref="document-info-btn"
6595
+ title="Document information" aria-label="Document information">
6596
+ ${INFO_ICON_HTML}
6597
+ </button>
6598
+ </div>
6599
+
6600
+ <header class="toolbar" data-ref="toolbar" hidden>
6601
+ <div class="toolbar-start">
6602
+ <div class="toolbar-left">
6603
+ <button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
6604
+ ${NAV_TOGGLE_ICON_HTML}
6605
+ </button>
6606
+ </div>
6607
+
6608
+ <div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
6609
+ <div class="edit-toolbar-group">
6610
+ <button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
6611
+ <button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
6612
+ <button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
6613
+ <div class="format-menu" data-format-control="headings">
6614
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
6615
+ aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
6616
+ ${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
6617
+ </button>
6618
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
6619
+ <button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
6620
+ <button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
6621
+ <button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
6622
+ <button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
6623
+ <button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
6624
+ <button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
6625
+ <button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
6626
+ </div>
6627
+ </div>
6628
+ </div>
6629
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
6630
+ <div class="edit-toolbar-group">
6631
+ <button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
6632
+ <button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
6633
+ <div class="format-menu" data-format-control="code">
6634
+ <button type="button" class="format-menu-toggle" data-format-menu-toggle
6635
+ aria-label="Code" aria-haspopup="menu" aria-expanded="false">
6636
+ ${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
6637
+ </button>
6638
+ <div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
6639
+ <button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
6640
+ <button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
6641
+ </div>
6642
+ </div>
6643
+ <button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
6644
+ <button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
6645
+ </div>
6646
+ <span class="edit-toolbar-divider" aria-hidden="true"></span>
6647
+ <div class="edit-toolbar-group">
6648
+ <button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
6649
+ <button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
6650
+ <input type="file" data-ref="image-input" accept="image/*" hidden />
6651
+ </div>
6652
+ </div>
6653
+ </div>
6654
+
6655
+ <div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
6656
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
6657
+ <span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
6658
+ </button>
6659
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
6660
+ <span class="commandbar-flex-container">
6661
+ ${SPLIT_ICON_HTML}
6662
+ </span>
6663
+ </button>
6664
+ <button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
6665
+ <span class="commandbar-flex-container">
6666
+ ${PREVIEW_ICON_HTML}
6667
+ </span>
6668
+ </button>
6669
+ </div>
6670
+
6671
+ <div class="toolbar-controls" data-ref="toolbar-controls">
6672
+ <button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
6673
+ ${SEARCH_ICON_HTML}
6674
+ </button>
6675
+ <button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
6676
+ ${SAVE_ICON_HTML}
6677
+ </button>
6678
+ <button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
6679
+ ${ZOOM_ICON_HTML}
6680
+ </button>
6681
+ <div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
6682
+ <button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
6683
+ title="Dark mode" aria-label="Dark mode" aria-pressed="false">
6684
+ ${DARK_THEME_ICON_HTML}
6685
+ </button>
6686
+ <button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
6687
+ title="Light mode" aria-label="Light mode" aria-pressed="false">
6688
+ ${LIGHT_THEME_ICON_HTML}
6689
+ </button>
6690
+ </div>
6691
+ <div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
6692
+ <span class="zoom-level" data-ref="zoom-level">100%</span>
6693
+ <span class="zoom-stepper">
6694
+ <button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
6695
+ <button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
6696
+ </span>
6697
+ <button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
6698
+ </div>
6699
+ </div>
6700
+ </header>
6701
+
6702
+ <div class="workspace-shell" data-ref="workspace-shell" hidden>
6703
+ <aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
6704
+ <div class="nav-tree" data-ref="nav-tree"></div>
6705
+ </aside>
6706
+ <div class="nav-resizer" data-ref="nav-resizer"
6707
+ role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
6708
+
6709
+ <div class="pane-stack" data-ref="pane-stack">
6710
+ <section class="pane edit-pane" data-ref="edit-pane">
6711
+ <div class="editor-host" data-ref="editor-host"></div>
6712
+ </section>
6713
+ <div class="split-resizer" data-ref="split-resizer"
6714
+ role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
6715
+ <section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
6716
+ <article class="preview-content" data-ref="preview-content"></article>
6717
+ </section>
6718
+ <section class="pane entry-pane" data-ref="entry-pane"></section>
6719
+ </div>
6720
+ </div>
6721
+
6722
+ <div class="title-dialog-backdrop" data-ref="title-dialog" hidden
6723
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
6724
+ <div class="title-dialog">
6725
+ <h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
6726
+ <p>This is the package-level title stored in manifest.json.</p>
6727
+ <input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
6728
+ <p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
6729
+ <p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
6730
+ <div class="title-dialog-actions">
6731
+ <button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
6732
+ <button type="button" data-action="cancel-title">Cancel</button>
6733
+ <button type="button" class="save-title" data-ref="title-save-btn">Save</button>
6734
+ </div>
6735
+ </div>
6736
+ </div>
6737
+
6738
+ <div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
6739
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
6740
+ <div class="title-dialog">
6741
+ <h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
6742
+ <p>
6743
+ Regular Markdown files contain only text. Convert this document to an
6744
+ MDZ package to add images, package navigation, and document metadata.
6745
+ </p>
6746
+ <p>The next save will produce an .mdz file.</p>
6747
+ <div class="title-dialog-actions">
6748
+ <button type="button" data-action="cancel-conversion">Cancel</button>
6749
+ <button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
6750
+ </div>
6751
+ </div>
6752
+ </div>
6753
+
6754
+ <div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
6755
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
6756
+ <div class="title-dialog image-insert-dialog">
6757
+ <h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
6758
+ <p>Choose the Markdown inserted for this image.</p>
6759
+ <fieldset class="image-insert-options">
6760
+ <legend>Output</legend>
6761
+ <label>
6762
+ <input type="radio" name="mdzip-image-insert-mode" value="markdown"
6763
+ data-ref="image-insert-mode-markdown" checked />
6764
+ Markdown image
6765
+ </label>
6766
+ <label>
6767
+ <input type="radio" name="mdzip-image-insert-mode" value="html"
6768
+ data-ref="image-insert-mode-html" />
6769
+ HTML &lt;img&gt; with sizing
6770
+ </label>
6771
+ </fieldset>
6772
+ <label class="image-insert-field">
6773
+ <span>Alt text</span>
6774
+ <input type="text" data-ref="image-insert-alt" />
6775
+ </label>
6776
+ <div class="image-insert-grid">
6777
+ <label class="image-insert-field">
6778
+ <span>Size by</span>
6779
+ <select data-ref="image-insert-size-mode">
6780
+ <option value="original">Original size</option>
6781
+ <option value="width" selected>Width</option>
6782
+ <option value="height">Height</option>
6783
+ <option value="percent">Percent</option>
6784
+ </select>
6785
+ </label>
6786
+ <label class="image-insert-field">
6787
+ <span>Value</span>
6788
+ <input type="number" min="1" step="1" data-ref="image-insert-size-value" />
6789
+ </label>
6790
+ </div>
6791
+ <label class="image-insert-field">
6792
+ <span>Position</span>
6793
+ <select data-ref="image-insert-position">
6794
+ <option value="inline">Inline/default</option>
6795
+ <option value="left">Left</option>
6796
+ <option value="center">Center</option>
6797
+ <option value="right">Right</option>
6798
+ <option value="wrap-left">Wrap left</option>
6799
+ <option value="wrap-right">Wrap right</option>
6800
+ </select>
6801
+ </label>
6802
+ <div class="title-dialog-actions">
6803
+ <button type="button" data-action="cancel-image-insert">Cancel</button>
6804
+ <button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
6805
+ </div>
6806
+ </div>
6807
+ </div>
6808
+
6809
+ <div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
6810
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
6811
+ <div class="title-dialog pack-files-dialog">
6812
+ <h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
6813
+ <p>These files contain more than one Markdown document. Choose how to package them.</p>
6814
+ <fieldset class="pack-files-options">
6815
+ <legend>Mode</legend>
6816
+ <label>
6817
+ <input type="radio" name="mdzip-pack-files-mode" value="document"
6818
+ data-ref="pack-files-mode-document" checked />
6819
+ Document — one primary page; the rest are attached
6820
+ </label>
6821
+ <label>
6822
+ <input type="radio" name="mdzip-pack-files-mode" value="project"
6823
+ data-ref="pack-files-mode-project" />
6824
+ Project — a set of related documents
6825
+ </label>
6826
+ </fieldset>
6827
+ <label class="pack-files-field">
6828
+ <span>Entry point</span>
6829
+ <select data-ref="pack-files-entry"></select>
6830
+ </label>
6831
+ <div class="title-dialog-actions">
6832
+ <button type="button" data-action="cancel-pack-files">Cancel</button>
6833
+ <button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
6834
+ </div>
6835
+ </div>
6836
+ </div>
6837
+
6838
+ <div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
6839
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
6840
+ <div class="title-dialog copy-render-dialog">
6841
+ <h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
6842
+ <div data-ref="copy-render-progress-section">
6843
+ <p data-ref="copy-render-progress-text">Rendering the full document...</p>
6844
+ <div class="copy-render-progress-track">
6845
+ <div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
6846
+ </div>
6847
+ </div>
6848
+ <p data-ref="copy-render-ready-text" hidden>
6849
+ The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
6850
+ click Copy to finish.
6851
+ </p>
6852
+ <p data-ref="copy-render-done-text" hidden></p>
6853
+ <div class="title-dialog-actions">
6854
+ <button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
6855
+ <button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
6856
+ <button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
6857
+ <button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
6858
+ </div>
6859
+ </div>
6860
+ </div>
6861
+
6862
+ <div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
6863
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
6864
+ <div class="title-dialog metadata-dialog">
6865
+ <h3 id="mdzip-metadata-dialog-heading">Document Information</h3>
6866
+ <dl data-ref="metadata-list"></dl>
6867
+ <h4>Libraries</h4>
6868
+ <dl data-ref="library-list"></dl>
6869
+ <div class="title-dialog-actions">
6870
+ <button type="button" class="save-title" data-action="close-metadata">Close</button>
6871
+ </div>
6872
+ </div>
6873
+ </div>
6874
+
6875
+ <div class="title-dialog-backdrop" data-ref="name-dialog" hidden
6876
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-name-dialog-heading">
6877
+ <div class="title-dialog">
6878
+ <h3 id="mdzip-name-dialog-heading" data-ref="name-dialog-heading">New Markdown File</h3>
6879
+ <input type="text" maxlength="260" data-ref="name-input" aria-label="File name" />
6880
+ <p class="title-dialog-validation" data-ref="name-validation" hidden></p>
6881
+ <div class="title-dialog-actions">
6882
+ <button type="button" data-action="cancel-name">Cancel</button>
6883
+ <button type="button" class="save-title" data-ref="name-confirm-btn">Create</button>
6884
+ </div>
6885
+ </div>
6886
+ </div>
6887
+
6888
+ <div class="title-dialog-backdrop" data-ref="delete-dialog" hidden
6889
+ role="dialog" aria-modal="true" aria-labelledby="mdzip-delete-dialog-heading">
6890
+ <div class="title-dialog">
6891
+ <h3 id="mdzip-delete-dialog-heading">Delete File?</h3>
6892
+ <p data-ref="delete-dialog-text"></p>
6893
+ <div class="title-dialog-actions">
6894
+ <button type="button" data-action="cancel-delete">Cancel</button>
6895
+ <button type="button" class="danger-action" data-ref="delete-confirm-btn">Delete</button>
6896
+ </div>
6897
+ </div>
6898
+ </div>
6899
+
6900
+ <input type="file" data-ref="replace-input" hidden />
6901
+
6902
+ <div class="nav-context-menu" data-ref="nav-menu" hidden role="menu"></div>
6903
+
6904
+ <div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
6905
+
6906
+ <div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
6907
+
6908
+ <p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
6909
+ </section>
6380
6910
  `;
6381
6911
  //# sourceMappingURL=view.js.map