@mdzip/editor 1.3.21 → 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.
- package/README.md +62 -0
- package/dist/archive-utils.d.ts.map +1 -1
- package/dist/archive-utils.js +16 -2
- package/dist/archive-utils.js.map +1 -1
- package/dist/asset-cache.d.ts +14 -0
- package/dist/asset-cache.d.ts.map +1 -1
- package/dist/asset-cache.js +57 -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/image-edit.d.ts +26 -0
- package/dist/image-edit.d.ts.map +1 -0
- package/dist/image-edit.js +195 -0
- package/dist/image-edit.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/library-info.d.ts +2 -2
- package/dist/library-info.js +2 -2
- package/dist/library-info.js.map +1 -1
- package/dist/mdz-archive-worker-protocol.d.ts +36 -0
- package/dist/mdz-archive-worker-protocol.d.ts.map +1 -0
- package/dist/mdz-archive-worker-protocol.js +2 -0
- package/dist/mdz-archive-worker-protocol.js.map +1 -0
- package/dist/mdz-archive.worker-client.d.ts +30 -0
- package/dist/mdz-archive.worker-client.d.ts.map +1 -0
- package/dist/mdz-archive.worker-client.js +97 -0
- package/dist/mdz-archive.worker-client.js.map +1 -0
- package/dist/mdz-archive.worker.d.ts +2 -0
- package/dist/mdz-archive.worker.d.ts.map +1 -0
- package/dist/mdz-archive.worker.js +8870 -0
- package/dist/mdz-archive.worker.js.map +1 -0
- 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 +114 -0
- package/dist/rendering.d.ts.map +1 -1
- package/dist/rendering.js +153 -1
- package/dist/rendering.js.map +1 -1
- package/dist/stats.d.ts +23 -0
- package/dist/stats.d.ts.map +1 -0
- package/dist/stats.js +31 -0
- package/dist/stats.js.map +1 -0
- package/dist/view-css.d.ts.map +1 -1
- package/dist/view-css.js +112 -0
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +421 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +2219 -410
- package/dist/view.js.map +1 -1
- package/dist/workspace.d.ts +14 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +53 -41
- package/dist/workspace.js.map +1 -1
- package/package.json +11 -5
package/dist/view.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
2
2
|
import { markdown } from '@codemirror/lang-markdown';
|
|
3
|
-
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
3
|
+
import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
|
4
4
|
import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemirror/search';
|
|
5
|
-
import { Compartment, EditorState } from '@codemirror/state';
|
|
6
|
-
import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
5
|
+
import { Compartment, EditorState, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
7
7
|
import { tags } from '@lezer/highlight';
|
|
8
8
|
import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
|
|
9
9
|
import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
|
|
@@ -13,7 +13,9 @@ import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
|
|
|
13
13
|
import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
|
|
14
14
|
import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
|
|
15
15
|
import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
|
|
16
|
-
import {
|
|
16
|
+
import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
|
|
17
|
+
import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks, chunkSourceKey, tokenEmbedsImage, tokenIsHeading } from './rendering.js';
|
|
18
|
+
import { mdzipFrontMatterExtension } from './front-matter-extension.js';
|
|
17
19
|
import { WORKSPACE_CSS } from './view-css.js';
|
|
18
20
|
const STYLE_ATTR = 'data-mdzip-ws-styles';
|
|
19
21
|
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
|
|
@@ -31,6 +33,7 @@ const IMAGE_ICON_HTML = lucideIcon(FileImage, NAV_ICON_CLASS);
|
|
|
31
33
|
const FILE_ICON_HTML = lucideIcon(File, NAV_ICON_CLASS);
|
|
32
34
|
const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
|
|
33
35
|
const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
|
|
36
|
+
const IMAGE_EDIT_AFFORDANCE_ICON_HTML = lucideIcon(SquarePen, 'mdzip-image-edit-affordance-icon');
|
|
34
37
|
const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
|
|
35
38
|
const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
|
|
36
39
|
const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS} convert-mdz-icon`);
|
|
@@ -433,6 +436,29 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
433
436
|
color: 'var(--mdzip-muted-foreground-color)',
|
|
434
437
|
opacity: '0.65',
|
|
435
438
|
},
|
|
439
|
+
'.mdzip-image-edit-affordance': {
|
|
440
|
+
display: 'inline-flex',
|
|
441
|
+
alignItems: 'center',
|
|
442
|
+
justifyContent: 'center',
|
|
443
|
+
width: '20px',
|
|
444
|
+
height: '20px',
|
|
445
|
+
marginLeft: '2px',
|
|
446
|
+
padding: '0',
|
|
447
|
+
verticalAlign: 'middle',
|
|
448
|
+
border: 'none',
|
|
449
|
+
borderRadius: '4px',
|
|
450
|
+
background: 'var(--mdzip-widget-background-color)',
|
|
451
|
+
color: 'var(--mdzip-muted-foreground-color)',
|
|
452
|
+
cursor: 'pointer',
|
|
453
|
+
},
|
|
454
|
+
'.mdzip-image-edit-affordance:hover': {
|
|
455
|
+
background: 'var(--mdzip-control-hover-background-color)',
|
|
456
|
+
color: 'var(--mdzip-control-foreground-color)',
|
|
457
|
+
},
|
|
458
|
+
'.mdzip-image-edit-affordance-icon': {
|
|
459
|
+
width: '13px',
|
|
460
|
+
height: '13px',
|
|
461
|
+
},
|
|
436
462
|
'.cm-panels': {
|
|
437
463
|
background: 'var(--mdzip-widget-background-color)',
|
|
438
464
|
color: 'var(--mdzip-editor-foreground-color)',
|
|
@@ -555,6 +581,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
|
|
|
555
581
|
}, {
|
|
556
582
|
decorations: value => value.decorations
|
|
557
583
|
});
|
|
584
|
+
// Fenced/indented code blocks, inline code spans, and link/image URLs aren't
|
|
585
|
+
// prose — the browser's spellchecker has no dictionary for shell syntax or
|
|
586
|
+
// filenames, so it just underlines everything. Syntax-tree node ranges (not
|
|
587
|
+
// a MatchDecorator regexp) are required here because MatchDecorator only
|
|
588
|
+
// matches within a single line, and fenced code blocks span many.
|
|
589
|
+
const NO_SPELLCHECK_NODE_NAMES = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'URL']);
|
|
590
|
+
const noSpellcheckMark = Decoration.mark({ attributes: { spellcheck: 'false' } });
|
|
591
|
+
function buildNoSpellcheckDecorations(view) {
|
|
592
|
+
const builder = new RangeSetBuilder();
|
|
593
|
+
for (const { from, to } of view.visibleRanges) {
|
|
594
|
+
syntaxTree(view.state).iterate({
|
|
595
|
+
from,
|
|
596
|
+
to,
|
|
597
|
+
enter: (node) => {
|
|
598
|
+
if (NO_SPELLCHECK_NODE_NAMES.has(node.name)) {
|
|
599
|
+
builder.add(node.from, node.to, noSpellcheckMark);
|
|
600
|
+
return false;
|
|
601
|
+
}
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
return builder.finish();
|
|
607
|
+
}
|
|
608
|
+
const noSpellcheckHighlight = ViewPlugin.fromClass(class {
|
|
609
|
+
constructor(view) {
|
|
610
|
+
this.decorations = buildNoSpellcheckDecorations(view);
|
|
611
|
+
}
|
|
612
|
+
update(update) {
|
|
613
|
+
if (update.docChanged || update.viewportChanged || syntaxTree(update.state) !== syntaxTree(update.startState)) {
|
|
614
|
+
this.decorations = buildNoSpellcheckDecorations(update.view);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}, {
|
|
618
|
+
decorations: value => value.decorations
|
|
619
|
+
});
|
|
620
|
+
// Click-to-edit affordance for existing image references. Populated
|
|
621
|
+
// on-click (not continuously, since the trigger is click not hover) via a
|
|
622
|
+
// StateEffect dispatched from the editor's `click` domEventHandler in
|
|
623
|
+
// createCmEditor. Fully inert unless a host imageEditHandler is set — see
|
|
624
|
+
// that click handler's own gate.
|
|
625
|
+
const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
|
|
626
|
+
const imageEditAffordanceEffect = StateEffect.define();
|
|
627
|
+
class ImageEditAffordanceWidget extends WidgetType {
|
|
628
|
+
constructor(from, to) {
|
|
629
|
+
super();
|
|
630
|
+
this.from = from;
|
|
631
|
+
this.to = to;
|
|
632
|
+
}
|
|
633
|
+
eq(other) {
|
|
634
|
+
return other.from === this.from && other.to === this.to;
|
|
635
|
+
}
|
|
636
|
+
toDOM() {
|
|
637
|
+
const btn = document.createElement('button');
|
|
638
|
+
btn.type = 'button';
|
|
639
|
+
btn.className = 'mdzip-image-edit-affordance';
|
|
640
|
+
btn.setAttribute('aria-label', 'Edit image');
|
|
641
|
+
btn.setAttribute('data-mdzip-image-edit-affordance', '');
|
|
642
|
+
btn.innerHTML = IMAGE_EDIT_AFFORDANCE_ICON_HTML;
|
|
643
|
+
// Buttons are natively focusable; without this, clicking one moves DOM
|
|
644
|
+
// focus onto it and blurs the CodeMirror content — which would clear
|
|
645
|
+
// (and remove from the DOM) this very widget via the blur handler below
|
|
646
|
+
// before its own click event has a chance to fire.
|
|
647
|
+
btn.addEventListener('mousedown', (event) => event.preventDefault());
|
|
648
|
+
btn.addEventListener('click', (event) => {
|
|
649
|
+
event.preventDefault();
|
|
650
|
+
event.stopPropagation();
|
|
651
|
+
btn.dispatchEvent(new CustomEvent(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, {
|
|
652
|
+
bubbles: true,
|
|
653
|
+
detail: { from: this.from, to: this.to }
|
|
654
|
+
}));
|
|
655
|
+
});
|
|
656
|
+
return btn;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function imageEditAffordanceDeco(from, to) {
|
|
660
|
+
return Decoration.set([Decoration.widget({ widget: new ImageEditAffordanceWidget(from, to), side: 1 }).range(to)]);
|
|
661
|
+
}
|
|
662
|
+
const imageEditAffordanceField = StateField.define({
|
|
663
|
+
create: () => Decoration.none,
|
|
664
|
+
update(value, tr) {
|
|
665
|
+
value = value.map(tr.changes);
|
|
666
|
+
if (tr.docChanged) {
|
|
667
|
+
value = Decoration.none;
|
|
668
|
+
}
|
|
669
|
+
for (const effect of tr.effects) {
|
|
670
|
+
if (effect.is(imageEditAffordanceEffect)) {
|
|
671
|
+
value = effect.value ? imageEditAffordanceDeco(effect.value.from, effect.value.to) : Decoration.none;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return value;
|
|
675
|
+
},
|
|
676
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
677
|
+
});
|
|
558
678
|
function injectStyles(doc) {
|
|
559
679
|
const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
|
|
560
680
|
if (existing) {
|
|
@@ -626,19 +746,19 @@ depth = 0) {
|
|
|
626
746
|
: isImageFile(node.entry.path)
|
|
627
747
|
? IMAGE_ICON_HTML
|
|
628
748
|
: FILE_ICON_HTML;
|
|
629
|
-
const orphanBtnHtml = isOrphaned && options.allowOrphanActions ? `
|
|
630
|
-
<span class="nav-orphan-button" role="button" tabindex="0"
|
|
631
|
-
title="Orphaned asset" aria-label="Orphaned asset actions"
|
|
632
|
-
data-orphan-path="${safePath}">
|
|
633
|
-
${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}
|
|
634
754
|
</span>` : '';
|
|
635
755
|
const draggable = options.allowDrag && !isManifest;
|
|
636
|
-
return `<button type="button" class="${classes}" title="${title}"
|
|
637
|
-
data-nav-path="${safePath}" data-orphan="${isOrphaned ? 'true' : ''}"${draggable ? ' draggable="true"' : ''}>
|
|
638
|
-
${guides}<span class="nav-caret"></span>
|
|
639
|
-
<span class="nav-file-icon ${iconKind}">${iconHtml}</span>
|
|
640
|
-
${orphanBtnHtml}
|
|
641
|
-
<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>
|
|
642
762
|
</button>`;
|
|
643
763
|
}
|
|
644
764
|
const childTrail = depth === 0 ? [] : [...trail, !isLast];
|
|
@@ -646,14 +766,14 @@ depth = 0) {
|
|
|
646
766
|
.map((child, index) => renderNavNode(child, state, options, childTrail, index === node.children.length - 1, depth + 1))
|
|
647
767
|
.join('');
|
|
648
768
|
const pending = options.pendingFolders.has(node.path.toLowerCase());
|
|
649
|
-
return `<details class="nav-directory${pending ? ' pending-folder' : ''}" open data-nav-dir="${escapeHtml(node.path)}">
|
|
650
|
-
<summary${pending ? ` title="${escapeHtml(node.path)} — not saved until it contains a file"` : ''}>
|
|
651
|
-
${guides}<span class="nav-caret" aria-hidden="true"></span>
|
|
652
|
-
<span class="nav-folder-icon closed">${FOLDER_CLOSED_ICON_HTML}</span>
|
|
653
|
-
<span class="nav-folder-icon open">${FOLDER_OPEN_ICON_HTML}</span>
|
|
654
|
-
<span class="nav-label">${escapeHtml(node.name)}</span>
|
|
655
|
-
</summary>
|
|
656
|
-
<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>
|
|
657
777
|
</details>`;
|
|
658
778
|
}
|
|
659
779
|
// Inserts view-local pending (not yet saved) folders into the nav tree so they
|
|
@@ -719,6 +839,8 @@ function rawHtmlImageAlignClass(image) {
|
|
|
719
839
|
return null;
|
|
720
840
|
}
|
|
721
841
|
export class MdzipWorkspaceView {
|
|
842
|
+
static { this.SCROLL_SYNC_SETTLE_MS = 250; }
|
|
843
|
+
static { this.SCROLL_SYNC_GESTURE_WINDOW_MS = 500; }
|
|
722
844
|
constructor(container, options = {}) {
|
|
723
845
|
this.workspace = null;
|
|
724
846
|
this.assetSession = null;
|
|
@@ -738,6 +860,60 @@ export class MdzipWorkspaceView {
|
|
|
738
860
|
// trigger) there's no "currently open document" to race against and no
|
|
739
861
|
// de-dupe/staleness guard is needed.
|
|
740
862
|
this.packFilesDialogState = null;
|
|
863
|
+
// Tracks an in-progress progressive (chunked) preview render so Copy All
|
|
864
|
+
// can force-drain whatever's left unmounted. `cursor` is how many of
|
|
865
|
+
// `records` are mounted so far; null once everything's mounted (or when
|
|
866
|
+
// progressive rendering isn't active at all) — that's Copy All's signal
|
|
867
|
+
// to skip the dialog and copy instantly. `sentinelHandle` is the
|
|
868
|
+
// scroll-driven continuation's IntersectionObserver, if one is currently
|
|
869
|
+
// armed; Copy All tears it down before manually draining so the two don't
|
|
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`.
|
|
878
|
+
this.chunkedRenderState = null;
|
|
879
|
+
// Per-generation memo of renderChunk's raw HTML output, keyed by chunk
|
|
880
|
+
// index, shared between the DOM-mount path (renderAndMountChunkBatch) and
|
|
881
|
+
// Copy All with Images (renderFullDocumentHtml) so whichever renders a
|
|
882
|
+
// given chunk first is the only one that pays for it. A chunk's HTML is
|
|
883
|
+
// written at most once per generation, never recomputed-and-compared: the
|
|
884
|
+
// shipped mermaid extension mints each diagram's SVG id from a counter
|
|
885
|
+
// that lives on the extension instance for the view's whole lifetime, so
|
|
886
|
+
// re-rendering the same chunk twice yields different-but-valid output —
|
|
887
|
+
// only a genuine write-once cache is safe to share across call sites.
|
|
888
|
+
// Deliberately a separate field from chunkedRenderState (not nested in
|
|
889
|
+
// it): that becomes null as soon as every chunk is mounted, but this
|
|
890
|
+
// needs to stay alive for the whole generation, including after mounting
|
|
891
|
+
// finishes — an idle, fully-mounted document is exactly when Copy All
|
|
892
|
+
// should get full reuse.
|
|
893
|
+
this.chunkHtmlCache = null;
|
|
894
|
+
// Promise-based like packFilesDialogState, but there's no caller awaiting
|
|
895
|
+
// a decision here — `abort` is Copy All's own cancellation switch, wired
|
|
896
|
+
// to the dialog's Cancel button. `label` distinguishes Copy All's single
|
|
897
|
+
// "Rendering the full document" phase from Copy All with Images' two
|
|
898
|
+
// phases ("Rendering the document" then "Embedding images").
|
|
899
|
+
this.copyRenderDialogState = null;
|
|
900
|
+
// Third state sharing the same dialog element as copyRenderDialogState
|
|
901
|
+
// (all three are mutually exclusive) — set once rendering/embedding
|
|
902
|
+
// finishes for a copy that showed the progress dialog. The clipboard
|
|
903
|
+
// write itself is deliberately *not* fired automatically: the async
|
|
904
|
+
// Clipboard API requires a recent user gesture, and by the time a
|
|
905
|
+
// multi-second (sometimes multi-minute) prepare phase finishes, the
|
|
906
|
+
// gesture that triggered Copy All has expired — Chrome rejects the write
|
|
907
|
+
// with "blocked due to lack of user activation". Waiting for the user to
|
|
908
|
+
// click the dialog's own Copy button gives the write a fresh gesture to
|
|
909
|
+
// run inside. `perform` does the actual write when that happens.
|
|
910
|
+
this.copyReadyState = null;
|
|
911
|
+
// Set once a copy that showed the progress dialog finishes (a rejection
|
|
912
|
+
// included — see `copyReadyState` above for why that write happens from
|
|
913
|
+
// a button click, not automatically), so the completion message stays up
|
|
914
|
+
// until the user dismisses it, rather than a fleeting toast easy to miss
|
|
915
|
+
// after a long wait.
|
|
916
|
+
this.copyRenderDoneState = null;
|
|
741
917
|
this.navPaneWidth = 280;
|
|
742
918
|
this.splitRatio = 0.5;
|
|
743
919
|
this.resizing = false;
|
|
@@ -763,6 +939,12 @@ export class MdzipWorkspaceView {
|
|
|
763
939
|
this.tooltipState = null;
|
|
764
940
|
this.tooltipShowTimer = null;
|
|
765
941
|
this.tooltipHideTimer = null;
|
|
942
|
+
this.copyToastHideTimer = null;
|
|
943
|
+
// Not readonly: tests override these to keep the "clipboard write hangs
|
|
944
|
+
// forever" case fast rather than actually waiting out the real production
|
|
945
|
+
// timeouts.
|
|
946
|
+
this.clipboardWriteTimeoutMs = 30000;
|
|
947
|
+
this.clipboardFallbackWriteTimeoutMs = 15000;
|
|
766
948
|
this.cmEditor = null;
|
|
767
949
|
this.readOnlyCompartment = new Compartment();
|
|
768
950
|
this.lineNumbersCompartment = new Compartment();
|
|
@@ -774,7 +956,64 @@ export class MdzipWorkspaceView {
|
|
|
774
956
|
// syncScrollFromPreview/syncScrollToPreview).
|
|
775
957
|
this.lastSyncedEditorScrollTop = null;
|
|
776
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
|
+
// Guards syncScrollToPreviewBottom against overlapping drains: set to the
|
|
1006
|
+
// chunkedRenderState generation currently being force-drained, null when
|
|
1007
|
+
// none is in flight. A second bottom-edge sync that arrives mid-drain
|
|
1008
|
+
// (e.g. repeated wheel events once the editor is already pinned at max
|
|
1009
|
+
// scrollTop) just no-ops — the in-flight call already owns finishing the
|
|
1010
|
+
// job for that generation.
|
|
1011
|
+
this.bottomDrainGeneration = null;
|
|
1012
|
+
// Non-null while the scroll-to-bottom catch-up toast is showing (past its
|
|
1013
|
+
// debounce) — see syncScrollToPreviewBottom.
|
|
1014
|
+
this.scrollCatchUpState = null;
|
|
777
1015
|
this.markdownExtensions = [];
|
|
1016
|
+
this.frontMatterOptions = {};
|
|
778
1017
|
this.entryRenderers = [];
|
|
779
1018
|
this.renderingService = new MdzipRenderingService();
|
|
780
1019
|
// Preview render memo: the preview pipeline only re-runs when one of these
|
|
@@ -797,6 +1036,7 @@ export class MdzipWorkspaceView {
|
|
|
797
1036
|
this.controlPolicy = resolveMdzipControlPolicy(options.controls);
|
|
798
1037
|
this.navigationMode = options.navigationMode ?? 'editor';
|
|
799
1038
|
this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
|
|
1039
|
+
this.progressiveTextRendering = options.progressiveTextRendering ?? false;
|
|
800
1040
|
this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
|
|
801
1041
|
this.contentDensity = options.contentDensity ?? 'comfortable';
|
|
802
1042
|
this.previewMaxWidth = options.previewMaxWidth;
|
|
@@ -808,8 +1048,9 @@ export class MdzipWorkspaceView {
|
|
|
808
1048
|
this.navVisible = options.navigationButtonActive ?? this.navVisible;
|
|
809
1049
|
this.markdownRenderer = options.markdownRenderer;
|
|
810
1050
|
this.markdownExtensions = options.markdownExtensions ?? [];
|
|
1051
|
+
this.frontMatterOptions = options.frontMatter ?? {};
|
|
811
1052
|
this.entryRenderers = options.entryRenderers ?? [];
|
|
812
|
-
this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.
|
|
1053
|
+
this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
|
|
813
1054
|
injectStyles(container.ownerDocument);
|
|
814
1055
|
container.replaceChildren();
|
|
815
1056
|
container.innerHTML = SHELL_HTML;
|
|
@@ -872,6 +1113,17 @@ export class MdzipWorkspaceView {
|
|
|
872
1113
|
this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
|
|
873
1114
|
this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
|
|
874
1115
|
this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
|
|
1116
|
+
this.elCopyRenderDialog = q('[data-ref="copy-render-dialog"]');
|
|
1117
|
+
this.elCopyRenderHeading = q('[data-ref="copy-render-heading"]');
|
|
1118
|
+
this.elCopyRenderProgressSection = q('[data-ref="copy-render-progress-section"]');
|
|
1119
|
+
this.elCopyRenderProgressBar = q('[data-ref="copy-render-progress-bar"]');
|
|
1120
|
+
this.elCopyRenderProgressText = q('[data-ref="copy-render-progress-text"]');
|
|
1121
|
+
this.elCopyRenderReadyText = q('[data-ref="copy-render-ready-text"]');
|
|
1122
|
+
this.elCopyRenderDoneText = q('[data-ref="copy-render-done-text"]');
|
|
1123
|
+
this.elCopyRenderCancelBtn = q('[data-ref="copy-render-cancel-btn"]');
|
|
1124
|
+
this.elCopyRenderReadyCancelBtn = q('[data-ref="copy-render-ready-cancel-btn"]');
|
|
1125
|
+
this.elCopyRenderConfirmBtn = q('[data-ref="copy-render-confirm-btn"]');
|
|
1126
|
+
this.elCopyRenderDismissBtn = q('[data-ref="copy-render-dismiss-btn"]');
|
|
875
1127
|
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
876
1128
|
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
877
1129
|
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
@@ -883,6 +1135,7 @@ export class MdzipWorkspaceView {
|
|
|
883
1135
|
this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
|
|
884
1136
|
this.elReplaceInput = q('[data-ref="replace-input"]');
|
|
885
1137
|
this.elTooltip = q('[data-ref="tooltip"]');
|
|
1138
|
+
this.elCopyToast = q('[data-ref="copy-toast"]');
|
|
886
1139
|
this.elEmptyState = q('[data-ref="empty-state"]');
|
|
887
1140
|
this.prepareTooltips();
|
|
888
1141
|
this.attachEvents();
|
|
@@ -904,6 +1157,7 @@ export class MdzipWorkspaceView {
|
|
|
904
1157
|
this.resetRenderingState();
|
|
905
1158
|
this.cmEditor?.destroy();
|
|
906
1159
|
this.cmEditor = null;
|
|
1160
|
+
this.workspace?.dispose();
|
|
907
1161
|
this.workspace = null;
|
|
908
1162
|
this.replaceAssetSession(null);
|
|
909
1163
|
this.conversionDocumentGeneration += 1;
|
|
@@ -985,6 +1239,7 @@ export class MdzipWorkspaceView {
|
|
|
985
1239
|
this.resetRenderingState();
|
|
986
1240
|
this.cmEditor?.destroy();
|
|
987
1241
|
this.cmEditor = null;
|
|
1242
|
+
this.workspace?.dispose();
|
|
988
1243
|
this.workspace = null;
|
|
989
1244
|
this.replaceAssetSession(null);
|
|
990
1245
|
this.conversionDocumentGeneration += 1;
|
|
@@ -1178,6 +1433,12 @@ export class MdzipWorkspaceView {
|
|
|
1178
1433
|
this.teardownEntryRenderer();
|
|
1179
1434
|
// Release any whenRendered() waiters so their promises do not hang.
|
|
1180
1435
|
this.flushRenderedWaiters();
|
|
1436
|
+
try {
|
|
1437
|
+
this.workspace?.dispose();
|
|
1438
|
+
}
|
|
1439
|
+
catch {
|
|
1440
|
+
// Ignore worker teardown errors
|
|
1441
|
+
}
|
|
1181
1442
|
try {
|
|
1182
1443
|
this.cmEditor?.destroy();
|
|
1183
1444
|
}
|
|
@@ -1204,15 +1465,46 @@ export class MdzipWorkspaceView {
|
|
|
1204
1465
|
if (options.markdownExtensions) {
|
|
1205
1466
|
this.markdownExtensions = options.markdownExtensions;
|
|
1206
1467
|
}
|
|
1468
|
+
if (options.frontMatter) {
|
|
1469
|
+
this.frontMatterOptions = options.frontMatter;
|
|
1470
|
+
}
|
|
1207
1471
|
if (options.entryRenderers) {
|
|
1208
1472
|
this.entryRenderers = options.entryRenderers;
|
|
1209
1473
|
}
|
|
1210
|
-
this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.
|
|
1474
|
+
this.renderingService = new MdzipRenderingService(this.markdownRenderer ?? defaultSafeMarkdownRenderer, this.pipelineMarkdownExtensions());
|
|
1211
1475
|
this.resetPreviewState();
|
|
1212
1476
|
this.teardownEntryRenderer();
|
|
1213
1477
|
this.entryMatchMissKey = null;
|
|
1214
1478
|
this.render();
|
|
1215
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
|
+
}
|
|
1216
1508
|
/**
|
|
1217
1509
|
* Replaces the view control policy without recreating the workspace or
|
|
1218
1510
|
* CodeMirror editor. In particular, `lineNumbers` is reconfigured through a
|
|
@@ -1290,6 +1582,9 @@ export class MdzipWorkspaceView {
|
|
|
1290
1582
|
this.options.imageInsertMode = options.imageInsertMode;
|
|
1291
1583
|
this.options.imageInsertHandler = options.imageInsertHandler;
|
|
1292
1584
|
}
|
|
1585
|
+
setImageEditOptions(options) {
|
|
1586
|
+
this.options.imageEditHandler = options.imageEditHandler;
|
|
1587
|
+
}
|
|
1293
1588
|
applyDensityClasses() {
|
|
1294
1589
|
this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
|
|
1295
1590
|
this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
|
|
@@ -1328,8 +1623,45 @@ export class MdzipWorkspaceView {
|
|
|
1328
1623
|
this.options.onFailed?.(error);
|
|
1329
1624
|
}
|
|
1330
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
|
+
}
|
|
1639
|
+
// A new render generation invalidates any in-progress chunk draining —
|
|
1640
|
+
// Copy All's own abort check unwinds it and hides the dialog. It also
|
|
1641
|
+
// invalidates a "ready to copy" or "done" dialog left over from a
|
|
1642
|
+
// previous document: `copyReadyState.perform` closes over that
|
|
1643
|
+
// document's already-built HTML/text, and clicking Copy against a
|
|
1644
|
+
// now-superseded document would silently copy the wrong content.
|
|
1645
|
+
this.chunkedRenderState = null;
|
|
1646
|
+
this.chunkHtmlCache = null;
|
|
1647
|
+
this.copyRenderDialogState?.abort.abort();
|
|
1648
|
+
this.copyReadyState = null;
|
|
1649
|
+
this.copyRenderDoneState = null;
|
|
1650
|
+
}
|
|
1651
|
+
/** True when `this.previewMemo` — and by extension `this.previewGeneration` — still represents `snapshot`: nothing that feeds the preview (path, pathType, text, colorScheme) has changed since it was last rendered. */
|
|
1652
|
+
previewMemoMatchesSnapshot(snapshot) {
|
|
1653
|
+
const memo = this.previewMemo;
|
|
1654
|
+
return !!memo
|
|
1655
|
+
&& memo.path === snapshot.currentPath
|
|
1656
|
+
&& memo.pathType === snapshot.currentPathType
|
|
1657
|
+
&& memo.text === snapshot.currentText
|
|
1658
|
+
&& memo.colorScheme === this.colorScheme;
|
|
1331
1659
|
}
|
|
1332
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;
|
|
1333
1665
|
if (entryClaimed) {
|
|
1334
1666
|
// The entry renderer owns the pane stack; release preview resources so
|
|
1335
1667
|
// a later fallback re-renders from scratch.
|
|
@@ -1343,20 +1675,39 @@ export class MdzipWorkspaceView {
|
|
|
1343
1675
|
return;
|
|
1344
1676
|
}
|
|
1345
1677
|
const memo = this.previewMemo;
|
|
1346
|
-
if (
|
|
1347
|
-
&& memo.path === snapshot.currentPath
|
|
1348
|
-
&& memo.pathType === snapshot.currentPathType
|
|
1349
|
-
&& memo.text === snapshot.currentText
|
|
1350
|
-
&& memo.colorScheme === this.colorScheme) {
|
|
1678
|
+
if (this.previewMemoMatchesSnapshot(snapshot)) {
|
|
1351
1679
|
// Nothing that feeds the preview changed; keep the existing DOM and any
|
|
1352
1680
|
// mounted extension handles.
|
|
1353
1681
|
return;
|
|
1354
1682
|
}
|
|
1355
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;
|
|
1356
1704
|
this.previewAbort?.abort();
|
|
1357
1705
|
this.previewAbort = null;
|
|
1358
|
-
|
|
1706
|
+
if (!priorChunkedState) {
|
|
1707
|
+
this.destroyPreviewHandles();
|
|
1708
|
+
}
|
|
1359
1709
|
const generation = ++this.previewGeneration;
|
|
1710
|
+
this.pendingPreviewScrollRestore = { scrollTop: previewScrollTopBeforeUpdate, generation };
|
|
1360
1711
|
this.previewHydrated = false;
|
|
1361
1712
|
this.previewMemo = {
|
|
1362
1713
|
path: snapshot.currentPath,
|
|
@@ -1388,6 +1739,15 @@ export class MdzipWorkspaceView {
|
|
|
1388
1739
|
const abort = new AbortController();
|
|
1389
1740
|
this.previewAbort = abort;
|
|
1390
1741
|
const context = this.createMarkdownContext(snapshot, abort.signal);
|
|
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
|
+
}
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1391
1751
|
let result;
|
|
1392
1752
|
try {
|
|
1393
1753
|
result = this.renderingService.renderMarkdown(snapshot.currentText, context);
|
|
@@ -1459,28 +1819,113 @@ export class MdzipWorkspaceView {
|
|
|
1459
1819
|
*/
|
|
1460
1820
|
mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
|
|
1461
1821
|
this.elPreviewContent.innerHTML = html;
|
|
1462
|
-
|
|
1463
|
-
|
|
1822
|
+
// Cheap pass over every <img> happens before extensions/code-block
|
|
1823
|
+
// controls mount, same relative order as always — see
|
|
1824
|
+
// collectPendingImages for why it's split from the (expensive) slot
|
|
1825
|
+
// creation that follows.
|
|
1826
|
+
const pending = this.collectPendingImages([this.elPreviewContent], context, generation, animateImageHydration);
|
|
1827
|
+
this.mountPreviewExtensions(context, generation);
|
|
1828
|
+
const codeBlockHandle = this.mountCodeBlockControls();
|
|
1829
|
+
if (codeBlockHandle) {
|
|
1830
|
+
this.previewHandles.push(codeBlockHandle);
|
|
1831
|
+
}
|
|
1832
|
+
this.firePreviewRendered(snapshot, generation);
|
|
1833
|
+
this.hydrateImages(pending, context, generation, animateImageHydration, () => {
|
|
1834
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
1835
|
+
});
|
|
1836
|
+
}
|
|
1837
|
+
/**
|
|
1838
|
+
* Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
|
|
1839
|
+
* mutation. Archive-relative sources have their `src` stripped here
|
|
1840
|
+
* (immediately, so the browser never fires a bad network request for the
|
|
1841
|
+
* archive-relative path) and are returned for {@link hydrateImages};
|
|
1842
|
+
* external/data/fragment sources are left untouched (just get their align
|
|
1843
|
+
* class, if any — they never get a slot). Returns `[]` without touching
|
|
1844
|
+
* anything when there's no asset session to resolve archive images
|
|
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`.
|
|
1857
|
+
*/
|
|
1858
|
+
collectPendingImages(roots, context, generation, animateImageHydration) {
|
|
1859
|
+
if (!this.assetSession) {
|
|
1860
|
+
return [];
|
|
1861
|
+
}
|
|
1464
1862
|
const pending = [];
|
|
1465
|
-
for (const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1863
|
+
for (const root of roots) {
|
|
1864
|
+
for (const image of Array.from(root.querySelectorAll('img'))) {
|
|
1865
|
+
applyRawHtmlImageSizeAttributes(image);
|
|
1866
|
+
const source = image.getAttribute('src');
|
|
1867
|
+
// Leave external, protocol-relative, data, and fragment URLs untouched.
|
|
1868
|
+
if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
|
|
1869
|
+
const alignClass = rawHtmlImageAlignClass(image);
|
|
1870
|
+
if (alignClass) {
|
|
1871
|
+
image.classList.add(alignClass);
|
|
1872
|
+
}
|
|
1873
|
+
continue;
|
|
1473
1874
|
}
|
|
1474
|
-
|
|
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
|
+
}
|
|
1891
|
+
image.removeAttribute('src');
|
|
1892
|
+
if (animateImageHydration) {
|
|
1893
|
+
image.classList.add('mdzip-image-loading');
|
|
1894
|
+
}
|
|
1895
|
+
pending.push({ image, source });
|
|
1475
1896
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1897
|
+
}
|
|
1898
|
+
return pending;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* Slot-wraps and resolves `pending` images (from {@link collectPendingImages}),
|
|
1902
|
+
* only decompressing/resolving ones near the viewport up front — a
|
|
1903
|
+
* document with a few hundred distinct embedded images would otherwise
|
|
1904
|
+
* decompress and blob-URL every one of them synchronously, freezing the UI
|
|
1905
|
+
* for as long as that takes. Falls back to eager resolution when
|
|
1906
|
+
* IntersectionObserver isn't available (e.g. non-browser hosts). Calls
|
|
1907
|
+
* `onSettled` once every image in `pending` has resolved (or immediately
|
|
1908
|
+
* if `pending` is empty) — callers that want a single "hydrated" signal
|
|
1909
|
+
* for a whole preview pass `pending` from every root at once; callers
|
|
1910
|
+
* mounting one chunk among several within the same generation (see
|
|
1911
|
+
* {@link mountChunkBatch}) pass a no-op instead, so the signal only fires
|
|
1912
|
+
* once, for the batch it's semantically tied to.
|
|
1913
|
+
*/
|
|
1914
|
+
hydrateImages(pending, context, generation, animateImageHydration, onSettled) {
|
|
1915
|
+
const session = this.assetSession;
|
|
1916
|
+
if (!session || pending.length === 0) {
|
|
1917
|
+
onSettled();
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
const document = this.elPreviewContent.ownerDocument;
|
|
1921
|
+
let remaining = pending.length;
|
|
1922
|
+
const settle = () => {
|
|
1923
|
+
remaining -= 1;
|
|
1924
|
+
if (remaining === 0) {
|
|
1925
|
+
onSettled();
|
|
1483
1926
|
}
|
|
1927
|
+
};
|
|
1928
|
+
const createImageSlot = (image) => {
|
|
1484
1929
|
const slot = document.createElement('span');
|
|
1485
1930
|
slot.className = animateImageHydration
|
|
1486
1931
|
? 'mdzip-image-slot'
|
|
@@ -1494,26 +1939,9 @@ export class MdzipWorkspaceView {
|
|
|
1494
1939
|
}
|
|
1495
1940
|
image.parentNode?.insertBefore(slot, image);
|
|
1496
1941
|
slot.appendChild(image);
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
this.mountPreviewExtensions(context, generation);
|
|
1500
|
-
const codeBlockHandle = this.mountCodeBlockControls();
|
|
1501
|
-
if (codeBlockHandle) {
|
|
1502
|
-
this.previewHandles.push(codeBlockHandle);
|
|
1503
|
-
}
|
|
1504
|
-
this.firePreviewRendered(snapshot, generation);
|
|
1505
|
-
if (!session || pending.length === 0) {
|
|
1506
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1507
|
-
return;
|
|
1508
|
-
}
|
|
1509
|
-
let remaining = pending.length;
|
|
1510
|
-
const settle = () => {
|
|
1511
|
-
remaining -= 1;
|
|
1512
|
-
if (remaining === 0) {
|
|
1513
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1514
|
-
}
|
|
1942
|
+
return slot;
|
|
1515
1943
|
};
|
|
1516
|
-
|
|
1944
|
+
const hydrate = (image, slot, source) => {
|
|
1517
1945
|
void session.resolveImage(source, context.currentPath).then((resolved) => {
|
|
1518
1946
|
if (generation !== this.previewGeneration || context.signal.aborted) {
|
|
1519
1947
|
settle();
|
|
@@ -1545,6 +1973,593 @@ export class MdzipWorkspaceView {
|
|
|
1545
1973
|
this.openImageSlot(slot);
|
|
1546
1974
|
settle();
|
|
1547
1975
|
});
|
|
1976
|
+
};
|
|
1977
|
+
const observerWindow = document.defaultView;
|
|
1978
|
+
if (!observerWindow?.IntersectionObserver) {
|
|
1979
|
+
for (const { image, source } of pending) {
|
|
1980
|
+
hydrate(image, createImageSlot(image), source);
|
|
1981
|
+
}
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
const bySlot = new Map();
|
|
1985
|
+
const observer = new observerWindow.IntersectionObserver((entries) => {
|
|
1986
|
+
for (const entry of entries) {
|
|
1987
|
+
if (!entry.isIntersecting)
|
|
1988
|
+
continue;
|
|
1989
|
+
observer.unobserve(entry.target);
|
|
1990
|
+
const item = bySlot.get(entry.target);
|
|
1991
|
+
if (item)
|
|
1992
|
+
hydrate(item.image, entry.target, item.source);
|
|
1993
|
+
}
|
|
1994
|
+
}, { root: this.elPreviewPane, rootMargin: '600px 0px' });
|
|
1995
|
+
this.previewHandles.push({ destroy: () => observer.disconnect() });
|
|
1996
|
+
// Slot creation + observer registration is a real DOM tree mutation per
|
|
1997
|
+
// image (as opposed to the cheap attribute-only pass in
|
|
1998
|
+
// collectPendingImages), and at thousands of occurrences (e.g. a chat
|
|
1999
|
+
// export where a handful of distinct avatars repeat across every row)
|
|
2000
|
+
// doing it all synchronously blocks the main thread for seconds.
|
|
2001
|
+
// Time-budget it across animation frames instead — the first chunk
|
|
2002
|
+
// still runs synchronously as part of this call (no yield before it),
|
|
2003
|
+
// so small documents (every existing test) see their slots exist
|
|
2004
|
+
// immediately, same as before.
|
|
2005
|
+
const CHUNK_BUDGET_MS = 8;
|
|
2006
|
+
let cursor = 0;
|
|
2007
|
+
const processChunk = () => {
|
|
2008
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2009
|
+
return;
|
|
2010
|
+
const chunkStart = observerWindow.performance.now();
|
|
2011
|
+
while (cursor < pending.length && observerWindow.performance.now() - chunkStart < CHUNK_BUDGET_MS) {
|
|
2012
|
+
const item = pending[cursor];
|
|
2013
|
+
cursor += 1;
|
|
2014
|
+
const slot = createImageSlot(item.image);
|
|
2015
|
+
bySlot.set(slot, item);
|
|
2016
|
+
observer.observe(slot);
|
|
2017
|
+
}
|
|
2018
|
+
if (cursor < pending.length) {
|
|
2019
|
+
observerWindow.requestAnimationFrame(processChunk);
|
|
2020
|
+
}
|
|
2021
|
+
};
|
|
2022
|
+
processChunk();
|
|
2023
|
+
}
|
|
2024
|
+
/**
|
|
2025
|
+
* Opt-in (`progressiveTextRendering`) alternative to the whole-document
|
|
2026
|
+
* `renderMarkdown()` path above: tokenizes the markdown once, then renders
|
|
2027
|
+
* and mounts it in chunks near the viewport instead of all at once — see
|
|
2028
|
+
* {@link mountChunkedPreview}. Only reachable when
|
|
2029
|
+
* `renderingService.supportsChunking` is true (the default marked-based
|
|
2030
|
+
* renderer); the caller already checked that.
|
|
2031
|
+
*/
|
|
2032
|
+
renderChunkedPreview(snapshot, context, generation, animateImageHydration) {
|
|
2033
|
+
let tokens;
|
|
2034
|
+
try {
|
|
2035
|
+
tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
|
|
2036
|
+
}
|
|
2037
|
+
catch (error) {
|
|
2038
|
+
this.options.onFailed?.(error);
|
|
2039
|
+
this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
|
|
2040
|
+
this.firePreviewRendered(snapshot, generation);
|
|
2041
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (Array.isArray(tokens)) {
|
|
2045
|
+
this.mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
void tokens.then((resolved) => {
|
|
2049
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2050
|
+
return;
|
|
2051
|
+
this.mountChunkedPreview(resolved, snapshot, context, generation, animateImageHydration);
|
|
2052
|
+
}).catch((error) => {
|
|
2053
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2054
|
+
return;
|
|
2055
|
+
if (error?.name !== 'AbortError') {
|
|
2056
|
+
this.options.onFailed?.(error);
|
|
2057
|
+
}
|
|
2058
|
+
});
|
|
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
|
+
}
|
|
2255
|
+
/**
|
|
2256
|
+
* Groups tokens into chunks and mounts them: an initial batch synchronously
|
|
2257
|
+
* (enough for a small document to behave exactly like the non-chunked
|
|
2258
|
+
* path), then the rest as the user scrolls near a trailing sentinel
|
|
2259
|
+
* element, via the same `IntersectionObserver` + time-budgeted-rAF pattern
|
|
2260
|
+
* already used for image hydration — just for "need more text" instead of
|
|
2261
|
+
* "need this image". `onAssetsHydrated` fires once for the initial batch's
|
|
2262
|
+
* images only (not re-fired per later chunk) — `onPreviewRendered` still
|
|
2263
|
+
* means "the initial batch is in the DOM", same intent as the non-chunked
|
|
2264
|
+
* path, just proportional to the viewport instead of the whole document.
|
|
2265
|
+
*/
|
|
2266
|
+
mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration) {
|
|
2267
|
+
this.elPreviewContent.replaceChildren();
|
|
2268
|
+
// Also called with no options in renderFullDocumentHtml — see the
|
|
2269
|
+
// comment there; keep both call sites' chunking in sync.
|
|
2270
|
+
const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
|
|
2271
|
+
if (chunks.length === 0) {
|
|
2272
|
+
this.chunkedRenderState = null;
|
|
2273
|
+
this.firePreviewRendered(snapshot, generation);
|
|
2274
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
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);
|
|
2343
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2344
|
+
return;
|
|
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;
|
|
2353
|
+
}
|
|
2354
|
+
this.firePreviewRendered(snapshot, generation);
|
|
2355
|
+
this.hydrateImages(allPending, context, generation, animateImageHydration, () => {
|
|
2356
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
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.
|
|
2370
|
+
*/
|
|
2371
|
+
recordChunkProgress(generation, records, cursor) {
|
|
2372
|
+
if (this.chunkedRenderState?.generation !== generation)
|
|
2373
|
+
return;
|
|
2374
|
+
this.chunkedRenderState.cursor = cursor;
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Renders+appends chunks starting at `startCursor` up to a char budget
|
|
2378
|
+
* (enough for one screenful, roughly) *or* a wall-clock time budget,
|
|
2379
|
+
* whichever comes first — char count alone is a poor proxy for cost on a
|
|
2380
|
+
* document where some chunks are plain text and others are dense with
|
|
2381
|
+
* `<img>` tags (parsing + sanitizing + inserting into an already-huge
|
|
2382
|
+
* `elPreviewContent` gets measurably more expensive per chunk as an
|
|
2383
|
+
* image-heavy document's accumulated DOM grows); the time budget catches
|
|
2384
|
+
* what the char budget alone misses. Mounts extensions/code-block controls
|
|
2385
|
+
* for exactly the chunks it appended (never re-scanning earlier ones), and
|
|
2386
|
+
* returns the cursor to resume from plus those chunks' roots (for the
|
|
2387
|
+
* caller to run `collectPendingImages` on). Shared by every caller that
|
|
2388
|
+
* mounts chunk batches — the initial batch, every later sentinel-triggered
|
|
2389
|
+
* continuation, and Copy All's drain — image hydration itself is *not*
|
|
2390
|
+
* included here; see {@link mountChunkBatch} and
|
|
2391
|
+
* {@link drainRemainingChunks} for the two different ways callers pace it.
|
|
2392
|
+
*/
|
|
2393
|
+
async renderAndMountChunkBatch(records, startCursor, context, generation, insertBeforeNode = null) {
|
|
2394
|
+
const BATCH_CHAR_BUDGET = 4000;
|
|
2395
|
+
const BATCH_TIME_BUDGET_MS = 10;
|
|
2396
|
+
const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
|
|
2397
|
+
const batchStart = clock.now();
|
|
2398
|
+
let cursor = startCursor;
|
|
2399
|
+
let renderedChars = 0;
|
|
2400
|
+
const mountedRoots = [];
|
|
2401
|
+
const batchStartCursor = startCursor;
|
|
2402
|
+
while (cursor < records.length
|
|
2403
|
+
&& renderedChars < BATCH_CHAR_BUDGET
|
|
2404
|
+
&& (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
|
|
2405
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2406
|
+
return { cursor, mountedRoots };
|
|
2407
|
+
const chunkTokens = records[cursor].tokens;
|
|
2408
|
+
const chunkIndex = cursor;
|
|
2409
|
+
cursor += 1;
|
|
2410
|
+
let html;
|
|
2411
|
+
let cacheHit;
|
|
2412
|
+
try {
|
|
2413
|
+
cacheHit = this.chunkHtmlCache?.generation === generation
|
|
2414
|
+
? this.chunkHtmlCache.html.get(chunkIndex)
|
|
2415
|
+
: undefined;
|
|
2416
|
+
if (cacheHit !== undefined) {
|
|
2417
|
+
html = cacheHit;
|
|
2418
|
+
}
|
|
2419
|
+
else {
|
|
2420
|
+
const result = this.renderingService.renderChunk(chunkTokens, context);
|
|
2421
|
+
html = typeof result === 'string' ? result : await result;
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
catch (error) {
|
|
2425
|
+
if (error?.name !== 'AbortError') {
|
|
2426
|
+
this.options.onFailed?.(error);
|
|
2427
|
+
}
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2431
|
+
return { cursor, mountedRoots };
|
|
2432
|
+
if (cacheHit === undefined) {
|
|
2433
|
+
// groupTokensIntoChunks is also called with no options in
|
|
2434
|
+
// renderFullDocumentHtml — keep both call sites' chunking in sync,
|
|
2435
|
+
// since chunk index is this cache's only key.
|
|
2436
|
+
if (this.chunkHtmlCache?.generation !== generation) {
|
|
2437
|
+
this.chunkHtmlCache = { generation, html: new Map() };
|
|
2438
|
+
}
|
|
2439
|
+
this.chunkHtmlCache.html.set(chunkIndex, html);
|
|
2440
|
+
}
|
|
2441
|
+
const root = this.appendChunkHtml(html, insertBeforeNode);
|
|
2442
|
+
records[chunkIndex].root = root;
|
|
2443
|
+
records[chunkIndex].html = html;
|
|
2444
|
+
mountedRoots.push(root);
|
|
2445
|
+
renderedChars += html.length;
|
|
2446
|
+
}
|
|
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);
|
|
2452
|
+
if (codeBlockHandle) {
|
|
2453
|
+
record.handles.push(codeBlockHandle);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
return { cursor, mountedRoots };
|
|
2457
|
+
}
|
|
2458
|
+
/**
|
|
2459
|
+
* `renderAndMountChunkBatch` plus its own immediate, independent
|
|
2460
|
+
* `hydrateImages` pass — the shape every caller except Copy All's drain
|
|
2461
|
+
* wants: mount a batch, then hydrate whatever images it contained,
|
|
2462
|
+
* decoupled from mounting the next batch. Scroll-paced callers (the
|
|
2463
|
+
* initial batch, every sentinel continuation) are naturally rate-limited
|
|
2464
|
+
* by how fast the user scrolls, so one independent hydration loop per
|
|
2465
|
+
* batch never has a chance to pile up against another.
|
|
2466
|
+
*/
|
|
2467
|
+
async mountChunkBatch(records, startCursor, context, generation, animateImageHydration, onImagesSettled) {
|
|
2468
|
+
const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(records, startCursor, context, generation);
|
|
2469
|
+
// Same relative order as the non-chunked path: cheap image pass, then
|
|
2470
|
+
// the (expensive) slot/observe pass.
|
|
2471
|
+
const pending = this.collectPendingImages(mountedRoots, context, generation, animateImageHydration);
|
|
2472
|
+
this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
|
|
2473
|
+
return cursor;
|
|
2474
|
+
}
|
|
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) {
|
|
2482
|
+
const doc = this.elPreviewContent.ownerDocument;
|
|
2483
|
+
const wrapper = doc.createElement('div');
|
|
2484
|
+
wrapper.className = 'mdzip-chunk';
|
|
2485
|
+
wrapper.innerHTML = html;
|
|
2486
|
+
if (beforeNode && beforeNode.parentNode === this.elPreviewContent) {
|
|
2487
|
+
this.elPreviewContent.insertBefore(wrapper, beforeNode);
|
|
2488
|
+
}
|
|
2489
|
+
else {
|
|
2490
|
+
this.elPreviewContent.appendChild(wrapper);
|
|
2491
|
+
}
|
|
2492
|
+
return wrapper;
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Appends a trailing sentinel and mounts the next chunk batch once it's
|
|
2496
|
+
* within `rootMargin` of the viewport — a larger margin than images'
|
|
2497
|
+
* (`hydrateImages`' 600px) since keeping text ahead of scroll is cheap
|
|
2498
|
+
* relative to keeping images ahead. Re-arms itself after each batch until
|
|
2499
|
+
* every chunk is mounted. Falls back to mounting everything immediately
|
|
2500
|
+
* when IntersectionObserver isn't available, matching `hydrateImages`.
|
|
2501
|
+
*/
|
|
2502
|
+
armChunkSentinel(records, cursor, context, generation, animateImageHydration) {
|
|
2503
|
+
const doc = this.elPreviewContent.ownerDocument;
|
|
2504
|
+
const observerWindow = doc.defaultView;
|
|
2505
|
+
const mountNext = (nextCursor) => {
|
|
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, () => { })
|
|
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);
|
|
2525
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2526
|
+
return;
|
|
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);
|
|
2532
|
+
}
|
|
2533
|
+
});
|
|
2534
|
+
};
|
|
2535
|
+
if (!observerWindow?.IntersectionObserver) {
|
|
2536
|
+
mountNext(cursor);
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const sentinel = doc.createElement('div');
|
|
2540
|
+
sentinel.className = 'mdzip-chunk-sentinel';
|
|
2541
|
+
this.elPreviewContent.appendChild(sentinel);
|
|
2542
|
+
const observer = new observerWindow.IntersectionObserver((entries) => {
|
|
2543
|
+
for (const entry of entries) {
|
|
2544
|
+
if (!entry.isIntersecting)
|
|
2545
|
+
continue;
|
|
2546
|
+
observer.disconnect();
|
|
2547
|
+
sentinel.remove();
|
|
2548
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
2549
|
+
this.chunkedRenderState.sentinelHandle = null;
|
|
2550
|
+
}
|
|
2551
|
+
mountNext(cursor);
|
|
2552
|
+
}
|
|
2553
|
+
}, { root: this.elPreviewPane, rootMargin: '1600px 0px' });
|
|
2554
|
+
observer.observe(sentinel);
|
|
2555
|
+
// Stored on both previewHandles (torn down on the next render generation,
|
|
2556
|
+
// like every other preview handle) and chunkedRenderState.sentinelHandle
|
|
2557
|
+
// (torn down early by Copy All so its own manual drain doesn't race this
|
|
2558
|
+
// observer and double-mount a chunk).
|
|
2559
|
+
const handle = { destroy: () => { observer.disconnect(); sentinel.remove(); } };
|
|
2560
|
+
this.previewHandles.push(handle);
|
|
2561
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
2562
|
+
this.chunkedRenderState.sentinelHandle = handle;
|
|
1548
2563
|
}
|
|
1549
2564
|
}
|
|
1550
2565
|
/**
|
|
@@ -1607,13 +2622,20 @@ export class MdzipWorkspaceView {
|
|
|
1607
2622
|
+ 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
|
|
1608
2623
|
+ 'blob: and data:.'));
|
|
1609
2624
|
}
|
|
1610
|
-
|
|
2625
|
+
/**
|
|
2626
|
+
* `root` scopes extension `mount()` calls to a specific chunk instead of
|
|
2627
|
+
* the whole preview — used by chunked rendering (see
|
|
2628
|
+
* {@link mountChunkedPreview}), where extensions run once per newly
|
|
2629
|
+
* appended chunk rather than once over the whole document. Defaults to
|
|
2630
|
+
* the whole preview content, matching the non-chunked path exactly.
|
|
2631
|
+
*/
|
|
2632
|
+
mountPreviewExtensions(context, generation, root = this.elPreviewContent, collector = this.previewHandles) {
|
|
1611
2633
|
for (const extension of this.markdownExtensions) {
|
|
1612
2634
|
if (!extension.mount) {
|
|
1613
2635
|
continue;
|
|
1614
2636
|
}
|
|
1615
2637
|
try {
|
|
1616
|
-
const mounted = extension.mount(
|
|
2638
|
+
const mounted = extension.mount(root, context);
|
|
1617
2639
|
if (isThenable(mounted)) {
|
|
1618
2640
|
void Promise.resolve(mounted).then((handle) => {
|
|
1619
2641
|
if (!handle) {
|
|
@@ -1628,7 +2650,7 @@ export class MdzipWorkspaceView {
|
|
|
1628
2650
|
}
|
|
1629
2651
|
return;
|
|
1630
2652
|
}
|
|
1631
|
-
|
|
2653
|
+
collector.push(handle);
|
|
1632
2654
|
}).catch((error) => {
|
|
1633
2655
|
if (generation === this.previewGeneration) {
|
|
1634
2656
|
this.options.onFailed?.(error);
|
|
@@ -1636,7 +2658,7 @@ export class MdzipWorkspaceView {
|
|
|
1636
2658
|
});
|
|
1637
2659
|
}
|
|
1638
2660
|
else if (mounted) {
|
|
1639
|
-
|
|
2661
|
+
collector.push(mounted);
|
|
1640
2662
|
}
|
|
1641
2663
|
}
|
|
1642
2664
|
catch (error) {
|
|
@@ -1653,12 +2675,15 @@ export class MdzipWorkspaceView {
|
|
|
1653
2675
|
* extension) — every consumer gets this automatically, gated by policy
|
|
1654
2676
|
* rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
|
|
1655
2677
|
* extension-provided `pre > code` blocks already exist in the DOM. See #29.
|
|
2678
|
+
*
|
|
2679
|
+
* `root` scopes the scan to a specific chunk instead of the whole preview
|
|
2680
|
+
* — see {@link mountPreviewExtensions} for why.
|
|
1656
2681
|
*/
|
|
1657
|
-
mountCodeBlockControls() {
|
|
2682
|
+
mountCodeBlockControls(root = this.elPreviewContent) {
|
|
1658
2683
|
if (!this.controlPolicy.codeBlockTools) {
|
|
1659
2684
|
return null;
|
|
1660
2685
|
}
|
|
1661
|
-
const codeEls = Array.from(
|
|
2686
|
+
const codeEls = Array.from(root.querySelectorAll('pre > code'));
|
|
1662
2687
|
if (codeEls.length === 0) {
|
|
1663
2688
|
return null;
|
|
1664
2689
|
}
|
|
@@ -1772,6 +2797,7 @@ export class MdzipWorkspaceView {
|
|
|
1772
2797
|
if (generation !== this.previewGeneration) {
|
|
1773
2798
|
return;
|
|
1774
2799
|
}
|
|
2800
|
+
this.restorePendingPreviewScroll(generation);
|
|
1775
2801
|
try {
|
|
1776
2802
|
this.options.onPreviewRendered?.(snapshot);
|
|
1777
2803
|
}
|
|
@@ -1779,6 +2805,20 @@ export class MdzipWorkspaceView {
|
|
|
1779
2805
|
this.options.onFailed?.(error);
|
|
1780
2806
|
}
|
|
1781
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
|
+
}
|
|
1782
2822
|
fireAssetsHydrated(snapshot, generation) {
|
|
1783
2823
|
if (generation !== this.previewGeneration) {
|
|
1784
2824
|
return;
|
|
@@ -2001,6 +3041,8 @@ export class MdzipWorkspaceView {
|
|
|
2001
3041
|
syntaxHighlighting(mdzipMarkdownHighlight),
|
|
2002
3042
|
hardBreakMarkerHighlight,
|
|
2003
3043
|
htmlTagMarkerHighlight,
|
|
3044
|
+
noSpellcheckHighlight,
|
|
3045
|
+
imageEditAffordanceField,
|
|
2004
3046
|
EditorView.lineWrapping,
|
|
2005
3047
|
dropCursor(),
|
|
2006
3048
|
// Content is contenteditable, but browsers don't agree on a default
|
|
@@ -2056,6 +3098,23 @@ export class MdzipWorkspaceView {
|
|
|
2056
3098
|
void self.handleEditorImageDrop(file, event.clientX, event.clientY);
|
|
2057
3099
|
return true;
|
|
2058
3100
|
}
|
|
3101
|
+
},
|
|
3102
|
+
click(event, view) {
|
|
3103
|
+
// Fully inert unless a host has opted in — no parse cost, no
|
|
3104
|
+
// widget, matching #39's "default editor behavior unchanged".
|
|
3105
|
+
if (!self.options.imageEditHandler) {
|
|
3106
|
+
return;
|
|
3107
|
+
}
|
|
3108
|
+
const target = event.target;
|
|
3109
|
+
if (target.closest('[data-mdzip-image-edit-affordance]')) {
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
3113
|
+
const hit = pos === null ? null : findImageReferenceAtOffset(view.state, pos);
|
|
3114
|
+
view.dispatch({ effects: imageEditAffordanceEffect.of(hit ? { from: hit.from, to: hit.to } : null) });
|
|
3115
|
+
},
|
|
3116
|
+
blur(_event, view) {
|
|
3117
|
+
view.dispatch({ effects: imageEditAffordanceEffect.of(null) });
|
|
2059
3118
|
}
|
|
2060
3119
|
}),
|
|
2061
3120
|
],
|
|
@@ -2064,8 +3123,25 @@ export class MdzipWorkspaceView {
|
|
|
2064
3123
|
// Attach scroll listener to sync with preview
|
|
2065
3124
|
const scroller = editor.dom.querySelector('.cm-scroller');
|
|
2066
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);
|
|
2067
3139
|
scroller.addEventListener('scroll', () => self.syncScrollToPreview());
|
|
2068
3140
|
}
|
|
3141
|
+
editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
|
|
3142
|
+
const { from, to } = event.detail;
|
|
3143
|
+
void self.openImageEditFlow(from, to);
|
|
3144
|
+
});
|
|
2069
3145
|
return editor;
|
|
2070
3146
|
}
|
|
2071
3147
|
async ensureCmEditor(force = false) {
|
|
@@ -2081,6 +3157,23 @@ export class MdzipWorkspaceView {
|
|
|
2081
3157
|
const snapshot = this.workspace?.snapshot() ?? null;
|
|
2082
3158
|
this.elEmptyState.hidden = snapshot !== null;
|
|
2083
3159
|
this.elWorkspaceShell.hidden = snapshot === null;
|
|
3160
|
+
// Must run before the no-snapshot early return below: the pack-files dialog is
|
|
3161
|
+
// deciding how to create a workspace, so it opens (via openPackFilesDialog ->
|
|
3162
|
+
// render()) precisely when no workspace exists yet. Bug found 2026-08-19 —
|
|
3163
|
+
// this used to live after the return, making it dead code for this dialog's
|
|
3164
|
+
// entire lifetime; the dialog's state was set correctly but never reached the
|
|
3165
|
+
// DOM, leaving the empty-state placeholder showing and the caller's promise
|
|
3166
|
+
// hanging forever.
|
|
3167
|
+
this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
|
|
3168
|
+
if (this.packFilesDialogState) {
|
|
3169
|
+
const { request } = this.packFilesDialogState;
|
|
3170
|
+
this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
|
|
3171
|
+
.map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
|
|
3172
|
+
.join('');
|
|
3173
|
+
this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
|
|
3174
|
+
this.elPackFilesModeDocument.checked = true;
|
|
3175
|
+
this.elPackFilesModeProject.checked = false;
|
|
3176
|
+
}
|
|
2084
3177
|
if (!snapshot) {
|
|
2085
3178
|
this.elDocumentStrip.hidden = true;
|
|
2086
3179
|
this.elToolbar.hidden = true;
|
|
@@ -2265,15 +3358,26 @@ export class MdzipWorkspaceView {
|
|
|
2265
3358
|
this.elImageInsertPositionSelect.value = 'inline';
|
|
2266
3359
|
this.updateImageInsertOptionControls();
|
|
2267
3360
|
}
|
|
2268
|
-
this.
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
3361
|
+
this.elCopyRenderDialog.hidden = this.copyRenderDialogState === null
|
|
3362
|
+
&& this.copyReadyState === null
|
|
3363
|
+
&& this.copyRenderDoneState === null;
|
|
3364
|
+
this.elCopyRenderProgressSection.hidden = this.copyRenderDialogState === null;
|
|
3365
|
+
this.elCopyRenderReadyText.hidden = this.copyReadyState === null;
|
|
3366
|
+
this.elCopyRenderDoneText.hidden = this.copyRenderDoneState === null;
|
|
3367
|
+
this.elCopyRenderCancelBtn.hidden = this.copyRenderDialogState === null;
|
|
3368
|
+
this.elCopyRenderReadyCancelBtn.hidden = this.copyReadyState === null;
|
|
3369
|
+
this.elCopyRenderConfirmBtn.hidden = this.copyReadyState === null;
|
|
3370
|
+
this.elCopyRenderDismissBtn.hidden = this.copyRenderDoneState === null;
|
|
3371
|
+
if (this.copyRenderDialogState) {
|
|
3372
|
+
this.elCopyRenderHeading.textContent = 'Copying document...';
|
|
3373
|
+
this.updateCopyRenderDialogProgress();
|
|
3374
|
+
}
|
|
3375
|
+
else if (this.copyReadyState) {
|
|
3376
|
+
this.elCopyRenderHeading.textContent = 'Ready to copy';
|
|
3377
|
+
}
|
|
3378
|
+
else if (this.copyRenderDoneState) {
|
|
3379
|
+
this.elCopyRenderHeading.textContent = 'Done';
|
|
3380
|
+
this.elCopyRenderDoneText.textContent = this.copyRenderDoneState.message;
|
|
2277
3381
|
}
|
|
2278
3382
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
2279
3383
|
if (this.contextMenuState) {
|
|
@@ -2367,10 +3471,11 @@ export class MdzipWorkspaceView {
|
|
|
2367
3471
|
}
|
|
2368
3472
|
});
|
|
2369
3473
|
// Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
|
|
2370
|
-
// both panes' text at once. Scope it to
|
|
2371
|
-
// focus is inside the preview pane (see the
|
|
2372
|
-
// how it gets there); the source editor
|
|
2373
|
-
// binding since CodeMirror's content root
|
|
3474
|
+
// both panes' text at once. Scope it to a Copy All of the rendered
|
|
3475
|
+
// content instead when focus is inside the preview pane (see the
|
|
3476
|
+
// mousedown handler below for how it gets there); the source editor
|
|
3477
|
+
// keeps its own defaultKeymap binding since CodeMirror's content root
|
|
3478
|
+
// sits outside this element.
|
|
2374
3479
|
doc.addEventListener('keydown', (e) => {
|
|
2375
3480
|
if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
|
|
2376
3481
|
return;
|
|
@@ -2379,7 +3484,7 @@ export class MdzipWorkspaceView {
|
|
|
2379
3484
|
return;
|
|
2380
3485
|
}
|
|
2381
3486
|
e.preventDefault();
|
|
2382
|
-
this.
|
|
3487
|
+
void this.copyAllPreviewContent();
|
|
2383
3488
|
});
|
|
2384
3489
|
this.elNavBtn.addEventListener('click', () => {
|
|
2385
3490
|
if (!this.controlPolicy.navigation) {
|
|
@@ -2650,6 +3755,28 @@ export class MdzipWorkspaceView {
|
|
|
2650
3755
|
this.elPackFilesConfirmBtn.addEventListener('click', () => {
|
|
2651
3756
|
this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
|
|
2652
3757
|
});
|
|
3758
|
+
this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-render"]')
|
|
3759
|
+
.addEventListener('click', () => {
|
|
3760
|
+
this.copyRenderDialogState?.abort.abort();
|
|
3761
|
+
});
|
|
3762
|
+
this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-ready"]')
|
|
3763
|
+
.addEventListener('click', () => {
|
|
3764
|
+
this.copyReadyState = null;
|
|
3765
|
+
this.render();
|
|
3766
|
+
});
|
|
3767
|
+
this.elCopyRenderDialog.querySelector('[data-action="confirm-copy-ready"]')
|
|
3768
|
+
.addEventListener('click', () => {
|
|
3769
|
+
// Must call the actual write synchronously from this handler (no
|
|
3770
|
+
// awaits ahead of it) — this click is the fresh user gesture the
|
|
3771
|
+
// Clipboard API needs; losing it to another async hop before the
|
|
3772
|
+
// write starts would defeat the entire reason this button exists.
|
|
3773
|
+
void this.performReadyCopy();
|
|
3774
|
+
});
|
|
3775
|
+
this.elCopyRenderDialog.querySelector('[data-action="dismiss-copy-render"]')
|
|
3776
|
+
.addEventListener('click', () => {
|
|
3777
|
+
this.copyRenderDoneState = null;
|
|
3778
|
+
this.render();
|
|
3779
|
+
});
|
|
2653
3780
|
this.elNavMenu.addEventListener('click', (e) => {
|
|
2654
3781
|
e.stopPropagation();
|
|
2655
3782
|
const item = e.target.closest('[data-menu-action]');
|
|
@@ -2782,6 +3909,11 @@ export class MdzipWorkspaceView {
|
|
|
2782
3909
|
void this.replaceFileFromPicker(path, file);
|
|
2783
3910
|
}
|
|
2784
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);
|
|
2785
3917
|
this.elPreviewPane.addEventListener('scroll', () => this.syncScrollFromPreview());
|
|
2786
3918
|
// Gives the pane logical focus on click so a subsequent Ctrl/Cmd+A (below)
|
|
2787
3919
|
// can be scoped to it. tabindex="-1" keeps it out of Tab order; this is
|
|
@@ -2915,6 +4047,18 @@ export class MdzipWorkspaceView {
|
|
|
2915
4047
|
this.tooltipHideTimer = null;
|
|
2916
4048
|
}, 40);
|
|
2917
4049
|
}
|
|
4050
|
+
/** Briefly shows a status message (e.g. "Text copied to clipboard") near the bottom of the view, auto-hiding after a couple of seconds. */
|
|
4051
|
+
showCopyToast(message) {
|
|
4052
|
+
if (this.copyToastHideTimer) {
|
|
4053
|
+
clearTimeout(this.copyToastHideTimer);
|
|
4054
|
+
}
|
|
4055
|
+
this.elCopyToast.textContent = message;
|
|
4056
|
+
this.elCopyToast.hidden = false;
|
|
4057
|
+
this.copyToastHideTimer = setTimeout(() => {
|
|
4058
|
+
this.elCopyToast.hidden = true;
|
|
4059
|
+
this.copyToastHideTimer = null;
|
|
4060
|
+
}, 2000);
|
|
4061
|
+
}
|
|
2918
4062
|
async save() {
|
|
2919
4063
|
try {
|
|
2920
4064
|
const workspace = this.workspace;
|
|
@@ -2969,15 +4113,35 @@ export class MdzipWorkspaceView {
|
|
|
2969
4113
|
}
|
|
2970
4114
|
renderMetadata(snapshot) {
|
|
2971
4115
|
const manifest = snapshot.content.manifest;
|
|
4116
|
+
const isMdz = snapshot.sourceFormat === 'mdz';
|
|
2972
4117
|
const fields = [
|
|
2973
4118
|
['Filename', snapshot.fileName],
|
|
2974
|
-
['Format',
|
|
4119
|
+
['Format', isMdz ? 'MDZ package' : 'Markdown'],
|
|
4120
|
+
// For .mdz, archiveBytes really is what a save right now would write —
|
|
4121
|
+
// current in-memory bytes, edits included, not a stale re-read of disk.
|
|
4122
|
+
// For plain Markdown, archiveBytes is *not* that: it's some internally
|
|
4123
|
+
// wrapped representation with its own fixed overhead (verified: a
|
|
4124
|
+
// 5-byte markdown document reported 462 "archive" bytes) — use the
|
|
4125
|
+
// actual text's encoded size instead, which is what a .md save writes.
|
|
4126
|
+
['Size', formatByteSize(isMdz ? snapshot.archiveBytes.length : new TextEncoder().encode(snapshot.currentText).length)],
|
|
2975
4127
|
['Document title', snapshot.displayTitle],
|
|
2976
4128
|
['First heading', snapshot.headingFallback ?? 'Not found'],
|
|
2977
4129
|
['Created', formatMetadataValue(manifest?.created)],
|
|
2978
4130
|
['Modified', formatMetadataValue(manifest?.modified)],
|
|
2979
|
-
['Entry point',
|
|
4131
|
+
['Entry point', isMdz ? snapshot.content.entryPoint : 'Not applicable'],
|
|
4132
|
+
['Documents', isMdz ? String(snapshot.workspace.documents.length) : 'Not applicable'],
|
|
4133
|
+
['Assets', isMdz ? String(snapshot.workspace.assets.length) : 'Not applicable']
|
|
2980
4134
|
];
|
|
4135
|
+
// Only shown when actually read-only — most documents are editable, and a
|
|
4136
|
+
// "Read-only: No" row for the common case would just be noise. Spelled
|
|
4137
|
+
// out as a filesystem condition rather than an editor state: this mode is
|
|
4138
|
+
// driven entirely by the host (see MdzipWorkspaceOpenOptions.mode) — most
|
|
4139
|
+
// often because a host checked the file's OS/disk permissions, as the
|
|
4140
|
+
// vscode extension does — not something toggled inside the editor itself,
|
|
4141
|
+
// so the wording should point users at their file, not at this UI.
|
|
4142
|
+
if (snapshot.mode === 'read-only') {
|
|
4143
|
+
fields.splice(1, 0, ['Read-only', 'Yes — the file on disk (or its host) is not writable']);
|
|
4144
|
+
}
|
|
2981
4145
|
this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
|
|
2982
4146
|
return this.createMetadataRow(label, value);
|
|
2983
4147
|
}));
|
|
@@ -3293,6 +4457,61 @@ export class MdzipWorkspaceView {
|
|
|
3293
4457
|
this.cmEditor.focus();
|
|
3294
4458
|
}
|
|
3295
4459
|
}
|
|
4460
|
+
/**
|
|
4461
|
+
* Handles a click on an existing image's edit affordance (see
|
|
4462
|
+
* `imageEditAffordanceField`/the `click` domEventHandler in
|
|
4463
|
+
* createCmEditor). Only reachable when `imageEditHandler` is set — the
|
|
4464
|
+
* click handler that dispatches the affordance already gates on that.
|
|
4465
|
+
*/
|
|
4466
|
+
async openImageEditFlow(from, to) {
|
|
4467
|
+
const editor = this.cmEditor;
|
|
4468
|
+
const handler = this.options.imageEditHandler;
|
|
4469
|
+
if (!editor || !handler) {
|
|
4470
|
+
return;
|
|
4471
|
+
}
|
|
4472
|
+
const parsed = findImageReferenceAtOffset(editor.state, from);
|
|
4473
|
+
if (!parsed || parsed.from !== from || parsed.to !== to) {
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
const request = {
|
|
4477
|
+
src: parsed.src,
|
|
4478
|
+
altText: parsed.altText,
|
|
4479
|
+
width: parsed.width,
|
|
4480
|
+
height: parsed.height,
|
|
4481
|
+
position: parsed.position,
|
|
4482
|
+
mode: parsed.kind
|
|
4483
|
+
};
|
|
4484
|
+
let decision;
|
|
4485
|
+
try {
|
|
4486
|
+
decision = normalizeImageInsertDecision(await handler(request));
|
|
4487
|
+
}
|
|
4488
|
+
catch (error) {
|
|
4489
|
+
this.options.onFailed?.(error);
|
|
4490
|
+
decision = null;
|
|
4491
|
+
}
|
|
4492
|
+
this.cmEditor?.dispatch({ effects: imageEditAffordanceEffect.of(null) });
|
|
4493
|
+
if (!decision) {
|
|
4494
|
+
return; // cancelled
|
|
4495
|
+
}
|
|
4496
|
+
const current = this.cmEditor;
|
|
4497
|
+
if (!current) {
|
|
4498
|
+
return;
|
|
4499
|
+
}
|
|
4500
|
+
// The handler may have awaited arbitrarily long — revalidate against the
|
|
4501
|
+
// live doc instead of trusting the captured range/text, in case it
|
|
4502
|
+
// changed underneath while the dialog was open.
|
|
4503
|
+
const revalidated = findImageReferenceAtOffset(current.state, from);
|
|
4504
|
+
if (!revalidated || revalidated.raw !== parsed.raw) {
|
|
4505
|
+
this.options.onFailed?.(new Error('Image reference changed before the edit could be applied.'));
|
|
4506
|
+
return;
|
|
4507
|
+
}
|
|
4508
|
+
const replacement = formatImageEditMarkdown(revalidated.src, decision);
|
|
4509
|
+
current.dispatch({
|
|
4510
|
+
changes: { from: revalidated.from, to: revalidated.to, insert: replacement },
|
|
4511
|
+
selection: { anchor: revalidated.from + replacement.length }
|
|
4512
|
+
});
|
|
4513
|
+
current.focus();
|
|
4514
|
+
}
|
|
3296
4515
|
async resolveImageInsertDecision(bytes, mimeType, options) {
|
|
3297
4516
|
const size = sniffImageSize(bytes, mimeType);
|
|
3298
4517
|
const request = {
|
|
@@ -3589,7 +4808,7 @@ export class MdzipWorkspaceView {
|
|
|
3589
4808
|
}
|
|
3590
4809
|
}
|
|
3591
4810
|
// Items for the rendered-preview selection menu. Taking over `contextmenu`
|
|
3592
|
-
// to offer
|
|
4811
|
+
// to offer Copy All also suppresses the browser's native menu — which is
|
|
3593
4812
|
// the only thing that was otherwise offering Copy on a right-click — so
|
|
3594
4813
|
// Copy has to be reinstated explicitly whenever there's a selection.
|
|
3595
4814
|
previewMenuItems() {
|
|
@@ -3603,11 +4822,16 @@ export class MdzipWorkspaceView {
|
|
|
3603
4822
|
items.push(null);
|
|
3604
4823
|
}
|
|
3605
4824
|
items.push({
|
|
3606
|
-
action: 'preview-
|
|
3607
|
-
label: '
|
|
3608
|
-
icon:
|
|
4825
|
+
action: 'preview-copy-all',
|
|
4826
|
+
label: 'Copy All',
|
|
4827
|
+
icon: MENU_COPY_ICON_HTML,
|
|
3609
4828
|
shortcut: this.editorShortcut('A')
|
|
3610
4829
|
});
|
|
4830
|
+
items.push({
|
|
4831
|
+
action: 'preview-copy-all-images',
|
|
4832
|
+
label: 'Copy All with Images',
|
|
4833
|
+
icon: MENU_COPY_ICON_HTML
|
|
4834
|
+
});
|
|
3611
4835
|
return items;
|
|
3612
4836
|
}
|
|
3613
4837
|
handlePreviewMenuAction(action) {
|
|
@@ -3618,41 +4842,458 @@ export class MdzipWorkspaceView {
|
|
|
3618
4842
|
return;
|
|
3619
4843
|
}
|
|
3620
4844
|
switch (action) {
|
|
3621
|
-
case 'preview-
|
|
3622
|
-
this.
|
|
4845
|
+
case 'preview-copy-all':
|
|
4846
|
+
void this.copyAllPreviewContent();
|
|
4847
|
+
break;
|
|
4848
|
+
case 'preview-copy-all-images':
|
|
4849
|
+
void this.copyAllWithImagesPreviewContent();
|
|
3623
4850
|
break;
|
|
3624
4851
|
case 'preview-copy':
|
|
3625
|
-
|
|
4852
|
+
// Partial Copy never shows the render dialog (there's nothing to
|
|
4853
|
+
// drain), so its confirmation is always the brief toast.
|
|
4854
|
+
void this.copyPreviewSelection(state.text).then((outcome) => this.finishCopyNotification(false, outcome));
|
|
3626
4855
|
break;
|
|
3627
4856
|
}
|
|
3628
4857
|
}
|
|
4858
|
+
/**
|
|
4859
|
+
* Shows the copy confirmation either as a brief auto-hiding toast (an
|
|
4860
|
+
* instant copy — nothing for the user to have looked away from) or, if
|
|
4861
|
+
* the render dialog was showing, by switching that same dialog into a
|
|
4862
|
+
* dismissable "done" state instead of hiding it — a copy that took long
|
|
4863
|
+
* enough to need a progress dialog shouldn't end in a 2-second toast the
|
|
4864
|
+
* user may not be looking at. `outcome` null means there was nothing to
|
|
4865
|
+
* copy (empty selection) — silently clean up the dialog if one was
|
|
4866
|
+
* showing, no confirmation needed. An `error` outcome carries the actual
|
|
4867
|
+
* failure text inline (`onFailed` is a host callback with no guaranteed
|
|
4868
|
+
* visible surface — this component has no other way to guarantee the
|
|
4869
|
+
* user ever sees why it failed).
|
|
4870
|
+
*/
|
|
4871
|
+
finishCopyNotification(dialogWasShown, outcome) {
|
|
4872
|
+
if (!outcome) {
|
|
4873
|
+
if (dialogWasShown) {
|
|
4874
|
+
this.copyRenderDialogState = null;
|
|
4875
|
+
this.render();
|
|
4876
|
+
}
|
|
4877
|
+
return;
|
|
4878
|
+
}
|
|
4879
|
+
const text = 'error' in outcome ? `Copy failed: ${outcome.error}` : outcome.message;
|
|
4880
|
+
if (dialogWasShown) {
|
|
4881
|
+
this.copyRenderDialogState = null;
|
|
4882
|
+
this.copyRenderDoneState = { message: text };
|
|
4883
|
+
this.render();
|
|
4884
|
+
}
|
|
4885
|
+
else {
|
|
4886
|
+
this.showCopyToast(text);
|
|
4887
|
+
}
|
|
4888
|
+
}
|
|
4889
|
+
/** Switches the render dialog into its "ready to copy" state, holding `perform` until the user clicks Copy — see `copyReadyState` for why the write can't just happen automatically here. */
|
|
4890
|
+
armCopyReady(perform) {
|
|
4891
|
+
this.copyRenderDialogState = null;
|
|
4892
|
+
this.copyReadyState = { perform };
|
|
4893
|
+
this.render();
|
|
4894
|
+
}
|
|
4895
|
+
/** Runs the held write from `copyReadyState` — called directly from the dialog's Copy button click, which is what makes the write's own user-activation check pass. */
|
|
4896
|
+
async performReadyCopy() {
|
|
4897
|
+
const ready = this.copyReadyState;
|
|
4898
|
+
if (!ready)
|
|
4899
|
+
return;
|
|
4900
|
+
this.copyReadyState = null;
|
|
4901
|
+
const outcome = await ready.perform();
|
|
4902
|
+
// Always the dialog path: copyReadyState only ever gets armed for a
|
|
4903
|
+
// copy that already showed the progress dialog.
|
|
4904
|
+
this.finishCopyNotification(true, outcome);
|
|
4905
|
+
}
|
|
4906
|
+
/**
|
|
4907
|
+
* Races `promise` against a timer, rejecting with `timeoutMessage` if the
|
|
4908
|
+
* timer wins. The async Clipboard API has no built-in timeout, and a
|
|
4909
|
+
* write large enough to strain a real OS clipboard (a document with
|
|
4910
|
+
* thousands of embedded images can build a HTML payload well over
|
|
4911
|
+
* 100MB) can apparently hang indefinitely on some machines rather than
|
|
4912
|
+
* rejecting — without this, that leaves the operation permanently
|
|
4913
|
+
* pending, and the progress dialog never resolves into either a done or
|
|
4914
|
+
* a failure state.
|
|
4915
|
+
*/
|
|
4916
|
+
async withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
4917
|
+
let timer;
|
|
4918
|
+
const timeout = new Promise((_, reject) => {
|
|
4919
|
+
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
4920
|
+
});
|
|
4921
|
+
try {
|
|
4922
|
+
return await Promise.race([promise, timeout]);
|
|
4923
|
+
}
|
|
4924
|
+
finally {
|
|
4925
|
+
clearTimeout(timer);
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4928
|
+
/** Writes `text` to the clipboard. Returns null if there was nothing to copy (a true no-op, not a failure); otherwise a success message or the failure text (also reported via `onFailed`, but that's a host callback with no guaranteed visible surface — the caller needs the text itself to show the user). */
|
|
3629
4929
|
async copyPreviewSelection(text) {
|
|
3630
4930
|
if (!text) {
|
|
3631
|
-
return;
|
|
4931
|
+
return null;
|
|
3632
4932
|
}
|
|
3633
4933
|
try {
|
|
3634
|
-
await this.editorClipboard()?.writeText(text);
|
|
4934
|
+
await this.withTimeout(Promise.resolve(this.editorClipboard()?.writeText(text)), this.clipboardFallbackWriteTimeoutMs, 'Clipboard write timed out.');
|
|
4935
|
+
return { message: 'Plain text copied to clipboard' };
|
|
3635
4936
|
}
|
|
3636
4937
|
catch (error) {
|
|
3637
4938
|
this.options.onFailed?.(error);
|
|
4939
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
3638
4940
|
}
|
|
3639
4941
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
const
|
|
3646
|
-
|
|
4942
|
+
/** Syncs just the copy-render dialog's progress bar/text from `copyRenderDialogState` — see `copyAllPreviewContent` for why this bypasses `render()`. No-op while the dialog isn't showing. */
|
|
4943
|
+
updateCopyRenderDialogProgress() {
|
|
4944
|
+
if (!this.copyRenderDialogState) {
|
|
4945
|
+
return;
|
|
4946
|
+
}
|
|
4947
|
+
const { done, total, label } = this.copyRenderDialogState;
|
|
4948
|
+
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
4949
|
+
this.elCopyRenderProgressBar.style.width = `${percent}%`;
|
|
4950
|
+
this.elCopyRenderProgressText.textContent = total > 0 ? `${label} (${done} / ${total})...` : `${label}...`;
|
|
4951
|
+
}
|
|
4952
|
+
/**
|
|
4953
|
+
* Copies the entire rendered document as plain text (same fidelity as
|
|
4954
|
+
* `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
|
|
4955
|
+
* much of it is currently mounted under progressive rendering. If
|
|
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.
|
|
4963
|
+
*/
|
|
4964
|
+
async copyAllPreviewContent() {
|
|
4965
|
+
const pending = this.chunkedRenderState;
|
|
4966
|
+
if (!pending || pending.cursor >= pending.records.length) {
|
|
4967
|
+
const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
|
|
4968
|
+
this.finishCopyNotification(false, outcome);
|
|
4969
|
+
return;
|
|
4970
|
+
}
|
|
4971
|
+
const total = pending.records.length;
|
|
4972
|
+
const abort = new AbortController();
|
|
4973
|
+
let dialogShown = false;
|
|
4974
|
+
const label = 'Rendering the full document';
|
|
4975
|
+
const showDialogTimer = setTimeout(() => {
|
|
4976
|
+
dialogShown = true;
|
|
4977
|
+
this.copyRenderDialogState = { done: pending.cursor, total, label, abort };
|
|
4978
|
+
this.render();
|
|
4979
|
+
}, 200);
|
|
4980
|
+
// Progress ticks update the dialog's progress bar/text directly instead
|
|
4981
|
+
// of going through the view's full `render()` — profiling a drain on a
|
|
4982
|
+
// 15,000-image document showed `render()` alone (it re-syncs the whole
|
|
4983
|
+
// shell, e.g. rebuilding the nav tree) costing ~870ms of self-time over
|
|
4984
|
+
// a 4s sample even throttled to 10/sec, dwarfing the actual chunk-mount
|
|
4985
|
+
// work it was meant to make room for.
|
|
4986
|
+
await this.drainRemainingChunks(pending, (done) => {
|
|
4987
|
+
if (!dialogShown)
|
|
4988
|
+
return;
|
|
4989
|
+
this.copyRenderDialogState = { done, total, label, abort };
|
|
4990
|
+
this.updateCopyRenderDialogProgress();
|
|
4991
|
+
}, abort.signal);
|
|
4992
|
+
clearTimeout(showDialogTimer);
|
|
4993
|
+
if (abort.signal.aborted) {
|
|
4994
|
+
if (dialogShown) {
|
|
4995
|
+
this.copyRenderDialogState = null;
|
|
4996
|
+
this.render();
|
|
4997
|
+
}
|
|
4998
|
+
// Cancelled partway through — whatever's left stays unmounted, so
|
|
4999
|
+
// re-arm the usual scroll-driven continuation for it.
|
|
5000
|
+
const remaining = this.chunkedRenderState;
|
|
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);
|
|
5004
|
+
}
|
|
5005
|
+
return;
|
|
5006
|
+
}
|
|
5007
|
+
if (dialogShown) {
|
|
5008
|
+
// The prepare phase above can take anywhere from seconds to minutes —
|
|
5009
|
+
// long enough that the click which started this has lost its user
|
|
5010
|
+
// activation by now, so the actual write waits for a fresh one
|
|
5011
|
+
// (the dialog's own Copy button) instead of firing automatically.
|
|
5012
|
+
// See copyReadyState's doc comment.
|
|
5013
|
+
this.armCopyReady(() => this.copyPreviewSelection(this.elPreviewContent.textContent ?? ''));
|
|
3647
5014
|
return;
|
|
3648
5015
|
}
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
5016
|
+
const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
|
|
5017
|
+
this.finishCopyNotification(false, outcome);
|
|
5018
|
+
}
|
|
5019
|
+
/**
|
|
5020
|
+
* Renders the *entire* current document to one HTML string, independent
|
|
5021
|
+
* of whatever's mounted in `elPreviewContent` — no DOM reads or writes.
|
|
5022
|
+
* Copy All with Images needs pristine `<img src="original/path">` markup
|
|
5023
|
+
* to hand to `MdzipAssetSession.rewriteHtmlEmbeddingImages`, and an
|
|
5024
|
+
* already-mounted, possibly-hydrated `<img>` in the live preview may have
|
|
5025
|
+
* had its `src` swapped for a `blob:` URL or stripped entirely pending lazy
|
|
5026
|
+
* load — either way the original archive path is gone. Time-budgeted and
|
|
5027
|
+
* yielded like `renderAndMountChunkBatch`, for the same reason: some
|
|
5028
|
+
* chunks cost far more to render+sanitize than others. Extension `mount()`
|
|
5029
|
+
* hooks are not run — they render into a live DOM (e.g. mermaid diagrams
|
|
5030
|
+
* turning marked-up code fences into SVG), which this never touches, so a
|
|
5031
|
+
* mermaid diagram will paste as its pre-render markup, not a rendered
|
|
5032
|
+
* diagram. Only reachable when `renderingService.supportsChunking` is
|
|
5033
|
+
* true; the caller checks that first.
|
|
5034
|
+
*/
|
|
5035
|
+
async renderFullDocumentHtml(snapshot, context, signal, cacheGeneration, onProgress) {
|
|
5036
|
+
const tokensResult = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
|
|
5037
|
+
const tokens = Array.isArray(tokensResult) ? tokensResult : await tokensResult;
|
|
5038
|
+
if (signal.aborted) {
|
|
5039
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
5040
|
+
}
|
|
5041
|
+
// groupTokensIntoChunks is also called with no options in
|
|
5042
|
+
// renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
|
|
5043
|
+
// call sites' chunking in sync, since chunk index is chunkHtmlCache's
|
|
5044
|
+
// only key.
|
|
5045
|
+
const chunks = groupTokensIntoChunks(tokens, this.chunkOptions());
|
|
5046
|
+
const view = this.elPreviewContent.ownerDocument.defaultView;
|
|
5047
|
+
const clock = view?.performance ?? performance;
|
|
5048
|
+
const BATCH_TIME_BUDGET_MS = 10;
|
|
5049
|
+
let html = '';
|
|
5050
|
+
let cursor = 0;
|
|
5051
|
+
while (cursor < chunks.length) {
|
|
5052
|
+
if (signal.aborted) {
|
|
5053
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
5054
|
+
}
|
|
5055
|
+
const batchStart = clock.now();
|
|
5056
|
+
let processedInBatch = 0;
|
|
5057
|
+
while (cursor < chunks.length && (processedInBatch === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
|
|
5058
|
+
const chunkIndex = cursor;
|
|
5059
|
+
const cacheHit = cacheGeneration !== null && this.chunkHtmlCache?.generation === cacheGeneration
|
|
5060
|
+
? this.chunkHtmlCache.html.get(chunkIndex)
|
|
5061
|
+
: undefined;
|
|
5062
|
+
let chunkHtml;
|
|
5063
|
+
if (cacheHit !== undefined) {
|
|
5064
|
+
chunkHtml = cacheHit;
|
|
5065
|
+
}
|
|
5066
|
+
else {
|
|
5067
|
+
const result = this.renderingService.renderChunk(chunks[chunkIndex], context);
|
|
5068
|
+
chunkHtml = typeof result === 'string' ? result : await result;
|
|
5069
|
+
// Only populate the shared cache if this generation is *still*
|
|
5070
|
+
// the live one at the moment this (possibly async) render
|
|
5071
|
+
// finished — checked per-chunk, not once up front, since the
|
|
5072
|
+
// outer loop yields via requestAnimationFrame and individual
|
|
5073
|
+
// chunk renders (mermaid) can themselves be async. If the
|
|
5074
|
+
// document was edited mid-copy, cacheGeneration is now stale
|
|
5075
|
+
// even though this render (for the snapshot Copy All captured)
|
|
5076
|
+
// legitimately continues to completion — never write it back
|
|
5077
|
+
// under a generation number that's no longer current.
|
|
5078
|
+
if (cacheGeneration !== null && cacheGeneration === this.previewGeneration) {
|
|
5079
|
+
if (this.chunkHtmlCache?.generation !== cacheGeneration) {
|
|
5080
|
+
this.chunkHtmlCache = { generation: cacheGeneration, html: new Map() };
|
|
5081
|
+
}
|
|
5082
|
+
this.chunkHtmlCache.html.set(chunkIndex, chunkHtml);
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
html += chunkHtml;
|
|
5086
|
+
cursor += 1;
|
|
5087
|
+
processedInBatch += 1;
|
|
5088
|
+
}
|
|
5089
|
+
onProgress?.(cursor, chunks.length);
|
|
5090
|
+
if (cursor >= chunks.length) {
|
|
5091
|
+
break;
|
|
5092
|
+
}
|
|
5093
|
+
if (signal.aborted) {
|
|
5094
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
5095
|
+
}
|
|
5096
|
+
await new Promise((resolve) => {
|
|
5097
|
+
if (view?.requestAnimationFrame)
|
|
5098
|
+
view.requestAnimationFrame(() => resolve());
|
|
5099
|
+
else
|
|
5100
|
+
setTimeout(resolve, 0);
|
|
5101
|
+
});
|
|
5102
|
+
}
|
|
5103
|
+
return html;
|
|
5104
|
+
}
|
|
5105
|
+
/**
|
|
5106
|
+
* Like `copyAllPreviewContent`, but writes a rich `text/html` clipboard
|
|
5107
|
+
* representation (alongside the same `text/plain` fallback) with every
|
|
5108
|
+
* archive image re-embedded as a self-contained `data:` URL — the format
|
|
5109
|
+
* an external app like Word needs, since this document's own `blob:` URLs
|
|
5110
|
+
* only resolve inside this tab. Two phases share one debounced, cancelable
|
|
5111
|
+
* dialog: render the document fresh (`renderFullDocumentHtml`), then embed
|
|
5112
|
+
* its images (`rewriteHtmlEmbeddingImages`). Falls back to
|
|
5113
|
+
* `copyAllPreviewContent`'s plain-text-only behavior when there's no
|
|
5114
|
+
* default renderer to re-render from (a host-supplied custom renderer,
|
|
5115
|
+
* the same escape hatch `progressiveTextRendering` already has) — or, if
|
|
5116
|
+
* the clipboard rejects the rich write for any reason (unsupported
|
|
5117
|
+
* browser, payload too large), falls back to a plain-text write so the
|
|
5118
|
+
* user still gets *something* rather than nothing.
|
|
5119
|
+
*/
|
|
5120
|
+
async copyAllWithImagesPreviewContent() {
|
|
5121
|
+
const snapshot = this.workspace?.snapshot();
|
|
5122
|
+
if (!snapshot || snapshot.currentPathType !== 'markdown' || !this.renderingService.supportsChunking) {
|
|
5123
|
+
await this.copyAllPreviewContent();
|
|
5124
|
+
return;
|
|
5125
|
+
}
|
|
5126
|
+
const abort = new AbortController();
|
|
5127
|
+
let dialogShown = false;
|
|
5128
|
+
const showDialogTimer = setTimeout(() => {
|
|
5129
|
+
dialogShown = true;
|
|
5130
|
+
this.copyRenderDialogState = { done: 0, total: 0, label: 'Rendering the document', abort };
|
|
5131
|
+
this.render();
|
|
5132
|
+
}, 200);
|
|
5133
|
+
const updateProgress = (done, total, label) => {
|
|
5134
|
+
if (!dialogShown)
|
|
5135
|
+
return;
|
|
5136
|
+
this.copyRenderDialogState = { done, total, label, abort };
|
|
5137
|
+
this.updateCopyRenderDialogProgress();
|
|
5138
|
+
};
|
|
5139
|
+
let finalHtml;
|
|
5140
|
+
let imageCount = 0;
|
|
5141
|
+
try {
|
|
5142
|
+
const context = this.createMarkdownContext(snapshot, abort.signal);
|
|
5143
|
+
// Only trust chunkHtmlCache if the live preview generation still
|
|
5144
|
+
// represents this exact snapshot — if the preview hasn't caught up to
|
|
5145
|
+
// it yet, or the document has since changed, render fully fresh
|
|
5146
|
+
// rather than risk splicing in HTML for different content.
|
|
5147
|
+
const cacheGeneration = this.previewMemoMatchesSnapshot(snapshot) ? this.previewGeneration : null;
|
|
5148
|
+
let html = await this.renderFullDocumentHtml(snapshot, context, abort.signal, cacheGeneration, (done, total) => updateProgress(done, total, 'Rendering the document'));
|
|
5149
|
+
if (this.assetSession) {
|
|
5150
|
+
html = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, abort.signal, (done, total) => {
|
|
5151
|
+
imageCount = total;
|
|
5152
|
+
updateProgress(done, total, 'Embedding images');
|
|
5153
|
+
});
|
|
5154
|
+
}
|
|
5155
|
+
finalHtml = html;
|
|
5156
|
+
}
|
|
5157
|
+
catch (error) {
|
|
5158
|
+
clearTimeout(showDialogTimer);
|
|
5159
|
+
if (error?.name === 'AbortError') {
|
|
5160
|
+
// Deliberate cancel — just close, no failure to report.
|
|
5161
|
+
if (dialogShown) {
|
|
5162
|
+
this.copyRenderDialogState = null;
|
|
5163
|
+
this.render();
|
|
5164
|
+
}
|
|
5165
|
+
}
|
|
5166
|
+
else {
|
|
5167
|
+
this.finishCopyNotification(dialogShown, { error: error instanceof Error ? error.message : String(error) });
|
|
5168
|
+
this.options.onFailed?.(error);
|
|
5169
|
+
}
|
|
5170
|
+
return;
|
|
5171
|
+
}
|
|
5172
|
+
clearTimeout(showDialogTimer);
|
|
5173
|
+
const scratch = this.elPreviewContent.ownerDocument.createElement('div');
|
|
5174
|
+
scratch.innerHTML = finalHtml;
|
|
5175
|
+
const plainText = scratch.textContent ?? '';
|
|
5176
|
+
if (dialogShown) {
|
|
5177
|
+
// Same reasoning as copyAllPreviewContent: the prepare phase (often
|
|
5178
|
+
// the slower of the two here, once image embedding is involved) has
|
|
5179
|
+
// long since burned through the click's user activation.
|
|
5180
|
+
this.armCopyReady(() => this.writeRichClipboard(finalHtml, plainText, imageCount));
|
|
5181
|
+
return;
|
|
5182
|
+
}
|
|
5183
|
+
const outcome = await this.writeRichClipboard(finalHtml, plainText, imageCount);
|
|
5184
|
+
this.finishCopyNotification(false, outcome);
|
|
5185
|
+
}
|
|
5186
|
+
/**
|
|
5187
|
+
* Writes an HTML+plain-text `ClipboardItem` when the browser supports it,
|
|
5188
|
+
* falling back to a plain-text-only `writeText` (same as
|
|
5189
|
+
* `copyPreviewSelection`, which reports itself as "Plain text") when it
|
|
5190
|
+
* doesn't, or if the rich write itself throws (e.g. a payload too large
|
|
5191
|
+
* for the OS clipboard) — either way the user ends up with *something* on
|
|
5192
|
+
* their clipboard rather than nothing. Returns the confirmation message
|
|
5193
|
+
* for the caller to show (see `finishCopyNotification`), naming the
|
|
5194
|
+
* actual MIME type that ended up on the clipboard ("HTML" for a
|
|
5195
|
+
* successful rich write, "Plain text" for the fallback) so the user knows
|
|
5196
|
+
* what they're about to paste — `imageCount` only adds to the "HTML"
|
|
5197
|
+
* wording, since even a rich write with zero images is still HTML, not
|
|
5198
|
+
* plain text.
|
|
5199
|
+
*/
|
|
5200
|
+
async writeRichClipboard(html, plainText, imageCount) {
|
|
5201
|
+
const clipboard = this.editorClipboard();
|
|
5202
|
+
const view = this.elPreviewContent.ownerDocument.defaultView;
|
|
5203
|
+
const ClipboardItemCtor = view?.ClipboardItem;
|
|
5204
|
+
if (clipboard && ClipboardItemCtor && 'write' in clipboard) {
|
|
5205
|
+
try {
|
|
5206
|
+
const item = new ClipboardItemCtor({
|
|
5207
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
5208
|
+
'text/plain': new Blob([plainText], { type: 'text/plain' })
|
|
5209
|
+
});
|
|
5210
|
+
// A document with thousands of repeated images (e.g. an avatar
|
|
5211
|
+
// reused across every row of a chat export) can build a payload
|
|
5212
|
+
// well over 100MB, since each occurrence embeds its own full copy
|
|
5213
|
+
// — the clipboard format has no way to reference a shared resource.
|
|
5214
|
+
// 30s is generous for a legitimate slow write; the real purpose is
|
|
5215
|
+
// making sure this can't hang forever with the dialog stuck mid-copy.
|
|
5216
|
+
await this.withTimeout(clipboard.write([item]), this.clipboardWriteTimeoutMs, 'Clipboard write timed out.');
|
|
5217
|
+
return {
|
|
5218
|
+
message: imageCount > 0
|
|
5219
|
+
? `HTML with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
|
|
5220
|
+
: 'HTML copied to clipboard'
|
|
5221
|
+
};
|
|
5222
|
+
}
|
|
5223
|
+
catch (error) {
|
|
5224
|
+
this.options.onFailed?.(error);
|
|
5225
|
+
// Fall through to the plain-text fallback rather than surfacing
|
|
5226
|
+
// this error directly — if that also fails, its error is more
|
|
5227
|
+
// relevant (it's the one actually blocking the user from getting
|
|
5228
|
+
// anything at all), and the plain-text path already reports this
|
|
5229
|
+
// one to onFailed for anyone watching that.
|
|
5230
|
+
}
|
|
5231
|
+
}
|
|
5232
|
+
return await this.copyPreviewSelection(plainText);
|
|
5233
|
+
}
|
|
5234
|
+
/**
|
|
5235
|
+
* Mounts every chunk from `state.cursor` onward, yielding to a fresh
|
|
5236
|
+
* animation frame between batches (unlike the sentinel path's rAF-time-
|
|
5237
|
+
* budgeted-per-batch loop, this has no viewport to wait on — it has to
|
|
5238
|
+
* plow through the whole rest of the document, so the explicit yield is
|
|
5239
|
+
* what keeps a huge draw from locking up the tab while it does). Stops
|
|
5240
|
+
* early if `signal` aborts, a newer render generation supersedes this one,
|
|
5241
|
+
* or the chunk state's own render context aborts.
|
|
5242
|
+
*
|
|
5243
|
+
* Deliberately uses `renderAndMountChunkBatch` instead of `mountChunkBatch`
|
|
5244
|
+
* and defers image hydration until the whole drain finishes, running it
|
|
5245
|
+
* once over every image the drain mounted, instead of once per batch:
|
|
5246
|
+
* Copy All only needs the mounted chunks' *text* (`elPreviewContent
|
|
5247
|
+
* .textContent`, which images never contribute to), so there's no reason
|
|
5248
|
+
* to wait on `hydrateImages`' async image resolution at all here — and
|
|
5249
|
+
* racing through batches as fast as this loop can while each one fires
|
|
5250
|
+
* its own independent, un-awaited `hydrateImages` background loop (as
|
|
5251
|
+
* `mountChunkBatch` does for the scroll-paced callers, where that's fine —
|
|
5252
|
+
* scrolling naturally rate-limits how many can ever be in flight at once)
|
|
5253
|
+
* lets dozens of those loops pile up concurrently on an image-heavy
|
|
5254
|
+
* document, each spending its own `CHUNK_BUDGET_MS` in the same frame —
|
|
5255
|
+
* measured 100-200ms frame gaps on a 15,000-image document. One combined
|
|
5256
|
+
* pass over every image at the end avoids the pile-up entirely, without
|
|
5257
|
+
* making Copy All wait on it.
|
|
5258
|
+
*/
|
|
5259
|
+
async drainRemainingChunks(state, onProgress, signal) {
|
|
5260
|
+
const { records, context, generation, animateImageHydration } = state;
|
|
5261
|
+
// Draining is instead of the scroll-driven continuation, not alongside
|
|
5262
|
+
// it — tearing down any armed sentinel first stops the two from racing
|
|
5263
|
+
// and double-mounting the same chunk.
|
|
5264
|
+
state.sentinelHandle?.destroy();
|
|
5265
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
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;
|
|
5271
|
+
}
|
|
5272
|
+
const allPending = [];
|
|
5273
|
+
try {
|
|
5274
|
+
while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < records.length) {
|
|
5275
|
+
if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
|
|
5276
|
+
return;
|
|
5277
|
+
const cursor = this.chunkedRenderState.cursor;
|
|
5278
|
+
const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(records, cursor, context, generation);
|
|
5279
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
5280
|
+
return;
|
|
5281
|
+
allPending.push(...this.collectPendingImages(mountedRoots, context, generation, animateImageHydration));
|
|
5282
|
+
this.recordChunkProgress(generation, records, newCursor);
|
|
5283
|
+
onProgress(newCursor, records.length);
|
|
5284
|
+
if (signal.aborted)
|
|
5285
|
+
return;
|
|
5286
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
finally {
|
|
5290
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
5291
|
+
this.chunkedRenderState.mounting = false;
|
|
5292
|
+
}
|
|
5293
|
+
if (generation === this.previewGeneration && !context.signal.aborted) {
|
|
5294
|
+
this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
|
|
5295
|
+
}
|
|
5296
|
+
}
|
|
3656
5297
|
}
|
|
3657
5298
|
// Inserts a fenced code block, carrying the chosen language as the fence info
|
|
3658
5299
|
// string. An empty language produces a plain ```` ``` ```` block.
|
|
@@ -4619,6 +6260,12 @@ export class MdzipWorkspaceView {
|
|
|
4619
6260
|
if (this.syncing || !this.cmEditor || this.layout !== 'split') {
|
|
4620
6261
|
return;
|
|
4621
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
|
+
}
|
|
4622
6269
|
const currentTop = this.elPreviewPane.scrollTop;
|
|
4623
6270
|
// Recognize this event as the echo of our own prior write (from
|
|
4624
6271
|
// syncScrollToPreview) by comparing values instead of racing timing.
|
|
@@ -4649,6 +6296,12 @@ export class MdzipWorkspaceView {
|
|
|
4649
6296
|
if (this.syncing || !this.cmEditor || this.layout !== 'split') {
|
|
4650
6297
|
return;
|
|
4651
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
|
+
}
|
|
4652
6305
|
const cmScroller = this.cmEditor.dom.querySelector('.cm-scroller');
|
|
4653
6306
|
if (!cmScroller) {
|
|
4654
6307
|
return;
|
|
@@ -4658,8 +6311,44 @@ export class MdzipWorkspaceView {
|
|
|
4658
6311
|
if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
|
|
4659
6312
|
return;
|
|
4660
6313
|
}
|
|
4661
|
-
this.syncing = true;
|
|
4662
6314
|
const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
|
|
6315
|
+
// The editor is at its true bottom, but under progressive rendering the
|
|
6316
|
+
// preview's scrollHeight only accounts for chunks mounted so far — a
|
|
6317
|
+
// plain ratio jump would only reach the bottom of whatever happens to be
|
|
6318
|
+
// mounted right now, not the document's actual end. Force-drain the rest
|
|
6319
|
+
// first so this edge is reliable regardless of how much has been
|
|
6320
|
+
// scrolled-into-view on the preview side.
|
|
6321
|
+
//
|
|
6322
|
+
// "At the bottom" is detected via CodeMirror's own viewport, not a
|
|
6323
|
+
// scrollHeight/scrollTop pixel comparison: CodeMirror estimates the
|
|
6324
|
+
// height of not-yet-measured (virtualized) lines, and on a huge document
|
|
6325
|
+
// that estimate can be tens of pixels off from where it actually clamps
|
|
6326
|
+
// scrollTop — confirmed live against a real 88,000-line file, where a
|
|
6327
|
+
// small fixed pixel epsilon never matched. `viewport.to` is what
|
|
6328
|
+
// CodeMirror has actually decided to draw for the current scroll
|
|
6329
|
+
// position, so comparing it to the document length is exact regardless
|
|
6330
|
+
// of any height estimation drift.
|
|
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) {
|
|
6348
|
+
void this.syncScrollToPreviewBottom();
|
|
6349
|
+
return;
|
|
6350
|
+
}
|
|
6351
|
+
this.syncing = true;
|
|
4663
6352
|
const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
|
|
4664
6353
|
const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
|
|
4665
6354
|
const target = scrollRatio * previewHeight;
|
|
@@ -4667,6 +6356,84 @@ export class MdzipWorkspaceView {
|
|
|
4667
6356
|
this.elPreviewPane.scrollTop = target;
|
|
4668
6357
|
this.syncing = false;
|
|
4669
6358
|
}
|
|
6359
|
+
/**
|
|
6360
|
+
* Handles syncScrollToPreview's bottom edge when the preview still has
|
|
6361
|
+
* unmounted chunks: force-mounts the rest (same drain Copy All uses) so
|
|
6362
|
+
* the preview's scrollHeight reflects the whole document, then jumps to
|
|
6363
|
+
* its real bottom — instead of the ordinary ratio-based jump, which would
|
|
6364
|
+
* only land at the bottom of whatever was mounted the instant the sync
|
|
6365
|
+
* fired. Re-checks that the editor is still at its bottom and the document
|
|
6366
|
+
* hasn't changed before applying the jump, since the drain can take long
|
|
6367
|
+
* enough on a huge document for either to no longer hold.
|
|
6368
|
+
*
|
|
6369
|
+
* On the most extreme real documents this drain has been measured at
|
|
6370
|
+
* ~106s (thousands of chunks, tens of thousands of images each needing a
|
|
6371
|
+
* real DOM slot + IntersectionObserver registration) — long enough that a
|
|
6372
|
+
* silent wait looks indistinguishable from a frozen preview pane. Past a
|
|
6373
|
+
* short debounce (so ordinary documents never see it), a small status
|
|
6374
|
+
* toast shows progress, reusing the same element Copy All's confirmation
|
|
6375
|
+
* messages use.
|
|
6376
|
+
*/
|
|
6377
|
+
async syncScrollToPreviewBottom() {
|
|
6378
|
+
const state = this.chunkedRenderState;
|
|
6379
|
+
if (!state || this.bottomDrainGeneration === state.generation) {
|
|
6380
|
+
return;
|
|
6381
|
+
}
|
|
6382
|
+
const generation = state.generation;
|
|
6383
|
+
this.bottomDrainGeneration = generation;
|
|
6384
|
+
if (this.copyToastHideTimer) {
|
|
6385
|
+
clearTimeout(this.copyToastHideTimer);
|
|
6386
|
+
this.copyToastHideTimer = null;
|
|
6387
|
+
}
|
|
6388
|
+
let showToastTimer = setTimeout(() => {
|
|
6389
|
+
showToastTimer = null;
|
|
6390
|
+
this.scrollCatchUpState = { done: state.cursor, total: state.records.length };
|
|
6391
|
+
this.updateScrollCatchUpToast();
|
|
6392
|
+
}, 200);
|
|
6393
|
+
try {
|
|
6394
|
+
await this.drainRemainingChunks(state, (done, total) => {
|
|
6395
|
+
if (!this.scrollCatchUpState)
|
|
6396
|
+
return;
|
|
6397
|
+
this.scrollCatchUpState = { done, total };
|
|
6398
|
+
this.updateScrollCatchUpToast();
|
|
6399
|
+
}, new AbortController().signal);
|
|
6400
|
+
}
|
|
6401
|
+
finally {
|
|
6402
|
+
if (showToastTimer) {
|
|
6403
|
+
clearTimeout(showToastTimer);
|
|
6404
|
+
}
|
|
6405
|
+
if (this.scrollCatchUpState) {
|
|
6406
|
+
this.scrollCatchUpState = null;
|
|
6407
|
+
this.elCopyToast.hidden = true;
|
|
6408
|
+
}
|
|
6409
|
+
if (this.bottomDrainGeneration === generation) {
|
|
6410
|
+
this.bottomDrainGeneration = null;
|
|
6411
|
+
}
|
|
6412
|
+
}
|
|
6413
|
+
if (generation !== this.previewGeneration || this.layout !== 'split' || !this.cmEditor) {
|
|
6414
|
+
return;
|
|
6415
|
+
}
|
|
6416
|
+
// See the comment in syncScrollToPreview on why this is viewport-based
|
|
6417
|
+
// rather than a scrollHeight/scrollTop pixel comparison.
|
|
6418
|
+
if (this.cmEditor.viewport.to < this.cmEditor.state.doc.length) {
|
|
6419
|
+
return;
|
|
6420
|
+
}
|
|
6421
|
+
this.syncing = true;
|
|
6422
|
+
const target = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
|
|
6423
|
+
this.lastSyncedPreviewScrollTop = target;
|
|
6424
|
+
this.elPreviewPane.scrollTop = target;
|
|
6425
|
+
this.syncing = false;
|
|
6426
|
+
}
|
|
6427
|
+
/** Syncs the catch-up toast's text from `scrollCatchUpState`, bypassing `render()` — see `updateCopyRenderDialogProgress` for why. No-op while the toast isn't showing. */
|
|
6428
|
+
updateScrollCatchUpToast() {
|
|
6429
|
+
if (!this.scrollCatchUpState) {
|
|
6430
|
+
return;
|
|
6431
|
+
}
|
|
6432
|
+
const { done, total } = this.scrollCatchUpState;
|
|
6433
|
+
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
6434
|
+
this.elCopyToast.textContent = total > 0 ? `Catching up the preview... (${percent}%)` : 'Catching up the preview...';
|
|
6435
|
+
this.elCopyToast.hidden = false;
|
|
6436
|
+
}
|
|
4670
6437
|
}
|
|
4671
6438
|
function canShowSourceLayout(snapshot) {
|
|
4672
6439
|
return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
|
|
@@ -4791,8 +6558,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
|
|
|
4791
6558
|
: after.startsWith('\n') ? '\n' : '\n\n';
|
|
4792
6559
|
return `${prefix}${html}${suffix}`;
|
|
4793
6560
|
}
|
|
4794
|
-
function
|
|
4795
|
-
|
|
6561
|
+
function formatByteSize(bytes) {
|
|
6562
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
6563
|
+
return 'Not available';
|
|
6564
|
+
}
|
|
6565
|
+
if (bytes < 1024) {
|
|
6566
|
+
return `${bytes} B`;
|
|
6567
|
+
}
|
|
6568
|
+
const units = ['KB', 'MB', 'GB'];
|
|
6569
|
+
let value = bytes / 1024;
|
|
6570
|
+
let unitIndex = 0;
|
|
6571
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
6572
|
+
value /= 1024;
|
|
6573
|
+
unitIndex += 1;
|
|
6574
|
+
}
|
|
6575
|
+
// One decimal below 10 of a unit (e.g. "1.4 MB"), none above (e.g. "23 MB") —
|
|
6576
|
+
// matches how OS file managers commonly round these.
|
|
6577
|
+
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString();
|
|
6578
|
+
return `${formatted} ${units[unitIndex]}`;
|
|
4796
6579
|
}
|
|
4797
6580
|
function formatMetadataValue(value) {
|
|
4798
6581
|
if (typeof value === 'string' && value.trim()) {
|
|
@@ -4804,299 +6587,325 @@ function formatMetadataValue(value) {
|
|
|
4804
6587
|
}
|
|
4805
6588
|
return 'Not available';
|
|
4806
6589
|
}
|
|
4807
|
-
const SHELL_HTML = `
|
|
4808
|
-
<section class="mdzip-root">
|
|
4809
|
-
<div class="document-strip" data-ref="document-strip" hidden>
|
|
4810
|
-
<button type="button" class="title-button" data-ref="title-btn"></button>
|
|
4811
|
-
<button type="button" class="document-info-button" data-ref="document-info-btn"
|
|
4812
|
-
title="Document information" aria-label="Document information">
|
|
4813
|
-
${INFO_ICON_HTML}
|
|
4814
|
-
</button>
|
|
4815
|
-
</div>
|
|
4816
|
-
|
|
4817
|
-
<header class="toolbar" data-ref="toolbar" hidden>
|
|
4818
|
-
<div class="toolbar-start">
|
|
4819
|
-
<div class="toolbar-left">
|
|
4820
|
-
<button type="button" class="icon-toggle nav-toggle" data-ref="nav-btn" title="Toggle contents" aria-label="Toggle contents">
|
|
4821
|
-
${NAV_TOGGLE_ICON_HTML}
|
|
4822
|
-
</button>
|
|
4823
|
-
</div>
|
|
4824
|
-
|
|
4825
|
-
<div class="edit-toolbar" data-ref="edit-toolbar" role="toolbar" aria-label="Markdown formatting">
|
|
4826
|
-
<div class="edit-toolbar-group">
|
|
4827
|
-
<button type="button" data-format="bold" data-format-control="bold" title="Bold" aria-label="Bold">${BOLD_ICON_HTML}</button>
|
|
4828
|
-
<button type="button" data-format="italic" data-format-control="italic" title="Italic" aria-label="Italic">${ITALIC_ICON_HTML}</button>
|
|
4829
|
-
<button type="button" data-format="strike" data-format-control="strikethrough" title="Strikethrough" aria-label="Strikethrough">${STRIKE_ICON_HTML}</button>
|
|
4830
|
-
<div class="format-menu" data-format-control="headings">
|
|
4831
|
-
<button type="button" class="format-menu-toggle" data-format-menu-toggle
|
|
4832
|
-
aria-label="Heading" aria-haspopup="menu" aria-expanded="false">
|
|
4833
|
-
${HEADING_ICON_HTML}${CHEVRON_ICON_HTML}
|
|
4834
|
-
</button>
|
|
4835
|
-
<div class="format-menu-popover" data-format-menu role="menu" aria-label="Heading level" hidden>
|
|
4836
|
-
<button type="button" role="menuitem" data-format="paragraph">Paragraph</button>
|
|
4837
|
-
<button type="button" role="menuitem" data-format="heading-1" data-heading-level="1"><strong>H1</strong> Heading 1</button>
|
|
4838
|
-
<button type="button" role="menuitem" data-format="heading-2" data-heading-level="2"><strong>H2</strong> Heading 2</button>
|
|
4839
|
-
<button type="button" role="menuitem" data-format="heading-3" data-heading-level="3"><strong>H3</strong> Heading 3</button>
|
|
4840
|
-
<button type="button" role="menuitem" data-format="heading-4" data-heading-level="4"><strong>H4</strong> Heading 4</button>
|
|
4841
|
-
<button type="button" role="menuitem" data-format="heading-5" data-heading-level="5"><strong>H5</strong> Heading 5</button>
|
|
4842
|
-
<button type="button" role="menuitem" data-format="heading-6" data-heading-level="6"><strong>H6</strong> Heading 6</button>
|
|
4843
|
-
</div>
|
|
4844
|
-
</div>
|
|
4845
|
-
</div>
|
|
4846
|
-
<span class="edit-toolbar-divider" aria-hidden="true"></span>
|
|
4847
|
-
<div class="edit-toolbar-group">
|
|
4848
|
-
<button type="button" data-format="bullet-list" data-format-control="bulletList" title="Bulleted list" aria-label="Bulleted list">${BULLET_LIST_ICON_HTML}</button>
|
|
4849
|
-
<button type="button" data-format="ordered-list" data-format-control="orderedList" title="Numbered list" aria-label="Numbered list">${ORDERED_LIST_ICON_HTML}</button>
|
|
4850
|
-
<div class="format-menu" data-format-control="code">
|
|
4851
|
-
<button type="button" class="format-menu-toggle" data-format-menu-toggle
|
|
4852
|
-
aria-label="Code" aria-haspopup="menu" aria-expanded="false">
|
|
4853
|
-
${CODE_ICON_HTML}${CHEVRON_ICON_HTML}
|
|
4854
|
-
</button>
|
|
4855
|
-
<div class="format-menu-popover" data-format-menu role="menu" aria-label="Code format" hidden>
|
|
4856
|
-
<button type="button" role="menuitem" data-format="code" data-code-kind="inline">Inline code</button>
|
|
4857
|
-
<button type="button" role="menuitem" data-format="code-block" data-code-kind="block">Code block</button>
|
|
4858
|
-
</div>
|
|
4859
|
-
</div>
|
|
4860
|
-
<button type="button" data-format="quote" data-format-control="blockquote" title="Blockquote" aria-label="Blockquote">${QUOTE_ICON_HTML}</button>
|
|
4861
|
-
<button type="button" data-format="line-break" data-format-control="lineBreak" title="Line break" aria-label="Line break">${LINE_BREAK_ICON_HTML}</button>
|
|
4862
|
-
</div>
|
|
4863
|
-
<span class="edit-toolbar-divider" aria-hidden="true"></span>
|
|
4864
|
-
<div class="edit-toolbar-group">
|
|
4865
|
-
<button type="button" data-format="link" data-format-control="link" title="Link" aria-label="Link">${LINK_ICON_HTML}</button>
|
|
4866
|
-
<button type="button" data-format="image" data-format-control="image" title="Image" aria-label="Image">${IMAGE_FORMAT_ICON_HTML}</button>
|
|
4867
|
-
<input type="file" data-ref="image-input" accept="image/*" hidden />
|
|
4868
|
-
</div>
|
|
4869
|
-
</div>
|
|
4870
|
-
</div>
|
|
4871
|
-
|
|
4872
|
-
<div class="toolbar-buttons view-mode-toggle-group" data-ref="layout-controls" role="group" aria-label="Editor layout">
|
|
4873
|
-
<button type="button" class="icon-toggle view-mode-toggle" data-ref="source-btn" title="Edit" aria-label="Edit" aria-pressed="false">
|
|
4874
|
-
<span class="commandbar-flex-container" data-ref="source-icon">${SOURCE_EDIT_ICON_HTML}</span>
|
|
4875
|
-
</button>
|
|
4876
|
-
<button type="button" class="icon-toggle view-mode-toggle" data-ref="split-btn" title="Split" aria-label="Split" aria-pressed="false">
|
|
4877
|
-
<span class="commandbar-flex-container">
|
|
4878
|
-
${SPLIT_ICON_HTML}
|
|
4879
|
-
</span>
|
|
4880
|
-
</button>
|
|
4881
|
-
<button type="button" class="icon-toggle view-mode-toggle" data-ref="preview-btn" title="View" aria-label="View" aria-pressed="false">
|
|
4882
|
-
<span class="commandbar-flex-container">
|
|
4883
|
-
${PREVIEW_ICON_HTML}
|
|
4884
|
-
</span>
|
|
4885
|
-
</button>
|
|
4886
|
-
</div>
|
|
4887
|
-
|
|
4888
|
-
<div class="toolbar-controls" data-ref="toolbar-controls">
|
|
4889
|
-
<button type="button" class="icon-toggle" data-ref="search-btn" title="Find/replace (Mod-F)" aria-label="Find/replace">
|
|
4890
|
-
${SEARCH_ICON_HTML}
|
|
4891
|
-
</button>
|
|
4892
|
-
<button type="button" class="icon-toggle" data-ref="save-btn" title="Save" aria-label="Save">
|
|
4893
|
-
${SAVE_ICON_HTML}
|
|
4894
|
-
</button>
|
|
4895
|
-
<button type="button" class="icon-toggle zoom-toggle" data-ref="zoom-btn" title="Zoom controls" aria-label="Zoom controls">
|
|
4896
|
-
${ZOOM_ICON_HTML}
|
|
4897
|
-
</button>
|
|
4898
|
-
<div class="theme-toggle-group" data-ref="theme-controls" role="group" aria-label="Color scheme">
|
|
4899
|
-
<button type="button" class="icon-toggle theme-toggle" data-ref="dark-theme-btn"
|
|
4900
|
-
title="Dark mode" aria-label="Dark mode" aria-pressed="false">
|
|
4901
|
-
${DARK_THEME_ICON_HTML}
|
|
4902
|
-
</button>
|
|
4903
|
-
<button type="button" class="icon-toggle theme-toggle" data-ref="light-theme-btn"
|
|
4904
|
-
title="Light mode" aria-label="Light mode" aria-pressed="false">
|
|
4905
|
-
${LIGHT_THEME_ICON_HTML}
|
|
4906
|
-
</button>
|
|
4907
|
-
</div>
|
|
4908
|
-
<div class="zoom-popover" data-ref="zoom-popover" hidden role="group" aria-label="Zoom">
|
|
4909
|
-
<span class="zoom-level" data-ref="zoom-level">100%</span>
|
|
4910
|
-
<span class="zoom-stepper">
|
|
4911
|
-
<button type="button" data-action="zoom-out" title="Zoom out" aria-label="Zoom out">-</button>
|
|
4912
|
-
<button type="button" data-action="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
|
|
4913
|
-
</span>
|
|
4914
|
-
<button type="button" class="zoom-reset" data-action="zoom-reset" title="Reset zoom">Reset</button>
|
|
4915
|
-
</div>
|
|
4916
|
-
</div>
|
|
4917
|
-
</header>
|
|
4918
|
-
|
|
4919
|
-
<div class="workspace-shell" data-ref="workspace-shell" hidden>
|
|
4920
|
-
<aside class="nav-pane" data-ref="nav-pane" aria-label="Package contents">
|
|
4921
|
-
<div class="nav-tree" data-ref="nav-tree"></div>
|
|
4922
|
-
</aside>
|
|
4923
|
-
<div class="nav-resizer" data-ref="nav-resizer"
|
|
4924
|
-
role="separator" aria-orientation="vertical" aria-label="Resize contents pane"></div>
|
|
4925
|
-
|
|
4926
|
-
<div class="pane-stack" data-ref="pane-stack">
|
|
4927
|
-
<section class="pane edit-pane" data-ref="edit-pane">
|
|
4928
|
-
<div class="editor-host" data-ref="editor-host"></div>
|
|
4929
|
-
</section>
|
|
4930
|
-
<div class="split-resizer" data-ref="split-resizer"
|
|
4931
|
-
role="separator" aria-orientation="vertical" aria-label="Resize split panes"></div>
|
|
4932
|
-
<section class="pane preview-pane" data-ref="preview-pane" tabindex="-1">
|
|
4933
|
-
<article class="preview-content" data-ref="preview-content"></article>
|
|
4934
|
-
</section>
|
|
4935
|
-
<section class="pane entry-pane" data-ref="entry-pane"></section>
|
|
4936
|
-
</div>
|
|
4937
|
-
</div>
|
|
4938
|
-
|
|
4939
|
-
<div class="title-dialog-backdrop" data-ref="title-dialog" hidden
|
|
4940
|
-
role="dialog" aria-modal="true" aria-labelledby="mdzip-title-dialog-heading">
|
|
4941
|
-
<div class="title-dialog">
|
|
4942
|
-
<h3 id="mdzip-title-dialog-heading">Set Document Title</h3>
|
|
4943
|
-
<p>This is the package-level title stored in manifest.json.</p>
|
|
4944
|
-
<input type="text" maxlength="120" data-ref="title-input" aria-label="Document title" />
|
|
4945
|
-
<p class="title-dialog-validation" data-ref="title-validation" hidden>Title cannot be empty.</p>
|
|
4946
|
-
<p>If unset, consumers may fall back to entry point, filename, or first heading.</p>
|
|
4947
|
-
<div class="title-dialog-actions">
|
|
4948
|
-
<button type="button" class="reset-title" data-ref="title-reset-btn">Reset</button>
|
|
4949
|
-
<button type="button" data-action="cancel-title">Cancel</button>
|
|
4950
|
-
<button type="button" class="save-title" data-ref="title-save-btn">Save</button>
|
|
4951
|
-
</div>
|
|
4952
|
-
</div>
|
|
4953
|
-
</div>
|
|
4954
|
-
|
|
4955
|
-
<div class="title-dialog-backdrop" data-ref="conversion-dialog" hidden
|
|
4956
|
-
role="dialog" aria-modal="true" aria-labelledby="mdzip-conversion-dialog-heading">
|
|
4957
|
-
<div class="title-dialog">
|
|
4958
|
-
<h3 id="mdzip-conversion-dialog-heading">Convert to MDZ?</h3>
|
|
4959
|
-
<p>
|
|
4960
|
-
Regular Markdown files contain only text. Convert this document to an
|
|
4961
|
-
MDZ package to add images, package navigation, and document metadata.
|
|
4962
|
-
</p>
|
|
4963
|
-
<p>The next save will produce an .mdz file.</p>
|
|
4964
|
-
<div class="title-dialog-actions">
|
|
4965
|
-
<button type="button" data-action="cancel-conversion">Cancel</button>
|
|
4966
|
-
<button type="button" class="save-title" data-ref="conversion-confirm-btn">Convert</button>
|
|
4967
|
-
</div>
|
|
4968
|
-
</div>
|
|
4969
|
-
</div>
|
|
4970
|
-
|
|
4971
|
-
<div class="title-dialog-backdrop" data-ref="image-insert-dialog" hidden
|
|
4972
|
-
role="dialog" aria-modal="true" aria-labelledby="mdzip-image-insert-dialog-heading">
|
|
4973
|
-
<div class="title-dialog image-insert-dialog">
|
|
4974
|
-
<h3 id="mdzip-image-insert-dialog-heading">Insert Image</h3>
|
|
4975
|
-
<p>Choose the Markdown inserted for this image.</p>
|
|
4976
|
-
<fieldset class="image-insert-options">
|
|
4977
|
-
<legend>Output</legend>
|
|
4978
|
-
<label>
|
|
4979
|
-
<input type="radio" name="mdzip-image-insert-mode" value="markdown"
|
|
4980
|
-
data-ref="image-insert-mode-markdown" checked />
|
|
4981
|
-
Markdown image
|
|
4982
|
-
</label>
|
|
4983
|
-
<label>
|
|
4984
|
-
<input type="radio" name="mdzip-image-insert-mode" value="html"
|
|
4985
|
-
data-ref="image-insert-mode-html" />
|
|
4986
|
-
HTML <img> with sizing
|
|
4987
|
-
</label>
|
|
4988
|
-
</fieldset>
|
|
4989
|
-
<label class="image-insert-field">
|
|
4990
|
-
<span>Alt text</span>
|
|
4991
|
-
<input type="text" data-ref="image-insert-alt" />
|
|
4992
|
-
</label>
|
|
4993
|
-
<div class="image-insert-grid">
|
|
4994
|
-
<label class="image-insert-field">
|
|
4995
|
-
<span>Size by</span>
|
|
4996
|
-
<select data-ref="image-insert-size-mode">
|
|
4997
|
-
<option value="original">Original size</option>
|
|
4998
|
-
<option value="width" selected>Width</option>
|
|
4999
|
-
<option value="height">Height</option>
|
|
5000
|
-
<option value="percent">Percent</option>
|
|
5001
|
-
</select>
|
|
5002
|
-
</label>
|
|
5003
|
-
<label class="image-insert-field">
|
|
5004
|
-
<span>Value</span>
|
|
5005
|
-
<input type="number" min="1" step="1" data-ref="image-insert-size-value" />
|
|
5006
|
-
</label>
|
|
5007
|
-
</div>
|
|
5008
|
-
<label class="image-insert-field">
|
|
5009
|
-
<span>Position</span>
|
|
5010
|
-
<select data-ref="image-insert-position">
|
|
5011
|
-
<option value="inline">Inline/default</option>
|
|
5012
|
-
<option value="left">Left</option>
|
|
5013
|
-
<option value="center">Center</option>
|
|
5014
|
-
<option value="right">Right</option>
|
|
5015
|
-
<option value="wrap-left">Wrap left</option>
|
|
5016
|
-
<option value="wrap-right">Wrap right</option>
|
|
5017
|
-
</select>
|
|
5018
|
-
</label>
|
|
5019
|
-
<div class="title-dialog-actions">
|
|
5020
|
-
<button type="button" data-action="cancel-image-insert">Cancel</button>
|
|
5021
|
-
<button type="button" class="save-title" data-ref="image-insert-confirm-btn">Insert</button>
|
|
5022
|
-
</div>
|
|
5023
|
-
</div>
|
|
5024
|
-
</div>
|
|
5025
|
-
|
|
5026
|
-
<div class="title-dialog-backdrop" data-ref="pack-files-dialog" hidden
|
|
5027
|
-
role="dialog" aria-modal="true" aria-labelledby="mdzip-pack-files-dialog-heading">
|
|
5028
|
-
<div class="title-dialog pack-files-dialog">
|
|
5029
|
-
<h3 id="mdzip-pack-files-dialog-heading">Package Markdown Files</h3>
|
|
5030
|
-
<p>These files contain more than one Markdown document. Choose how to package them.</p>
|
|
5031
|
-
<fieldset class="pack-files-options">
|
|
5032
|
-
<legend>Mode</legend>
|
|
5033
|
-
<label>
|
|
5034
|
-
<input type="radio" name="mdzip-pack-files-mode" value="document"
|
|
5035
|
-
data-ref="pack-files-mode-document" checked />
|
|
5036
|
-
Document — one primary page; the rest are attached
|
|
5037
|
-
</label>
|
|
5038
|
-
<label>
|
|
5039
|
-
<input type="radio" name="mdzip-pack-files-mode" value="project"
|
|
5040
|
-
data-ref="pack-files-mode-project" />
|
|
5041
|
-
Project — a set of related documents
|
|
5042
|
-
</label>
|
|
5043
|
-
</fieldset>
|
|
5044
|
-
<label class="pack-files-field">
|
|
5045
|
-
<span>Entry point</span>
|
|
5046
|
-
<select data-ref="pack-files-entry"></select>
|
|
5047
|
-
</label>
|
|
5048
|
-
<div class="title-dialog-actions">
|
|
5049
|
-
<button type="button" data-action="cancel-pack-files">Cancel</button>
|
|
5050
|
-
<button type="button" class="save-title" data-ref="pack-files-confirm-btn">Package</button>
|
|
5051
|
-
</div>
|
|
5052
|
-
</div>
|
|
5053
|
-
</div>
|
|
5054
|
-
|
|
5055
|
-
<div class="title-dialog-backdrop" data-ref="
|
|
5056
|
-
role="dialog" aria-modal="true" aria-labelledby="mdzip-
|
|
5057
|
-
<div class="title-dialog
|
|
5058
|
-
<h3 id="mdzip-
|
|
5059
|
-
<
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
</div>
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
<
|
|
5085
|
-
<
|
|
5086
|
-
<div class="title-dialog-actions">
|
|
5087
|
-
<button type="button" data-action="
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
|
|
5096
|
-
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
</
|
|
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 <img> 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>
|
|
5101
6910
|
`;
|
|
5102
6911
|
//# sourceMappingURL=view.js.map
|