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