@mdzip/editor 1.3.21 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -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 +12 -0
- package/dist/asset-cache.d.ts.map +1 -1
- package/dist/asset-cache.js +45 -0
- package/dist/asset-cache.js.map +1 -1
- 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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/library-info.d.ts +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/rendering.d.ts +44 -0
- package/dist/rendering.d.ts.map +1 -1
- package/dist/rendering.js +92 -0
- 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 +45 -0
- package/dist/view-css.js.map +1 -1
- package/dist/view.d.ts +319 -1
- package/dist/view.d.ts.map +1 -1
- package/dist/view.js +1371 -92
- 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 +9 -5
package/dist/view.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
|
2
2
|
import { markdown } from '@codemirror/lang-markdown';
|
|
3
|
-
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
|
3
|
+
import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language';
|
|
4
4
|
import { closeSearchPanel, openSearchPanel, search, searchKeymap } from '@codemirror/search';
|
|
5
|
-
import { Compartment, EditorState } from '@codemirror/state';
|
|
6
|
-
import { Decoration, EditorView, MatchDecorator, ViewPlugin, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
5
|
+
import { Compartment, EditorState, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
+
import { Decoration, EditorView, MatchDecorator, ViewPlugin, WidgetType, dropCursor, keymap, lineNumbers } from '@codemirror/view';
|
|
7
7
|
import { tags } from '@lezer/highlight';
|
|
8
8
|
import { Bold, Check, ChevronDown, ChevronRight, ClipboardPaste, ClipboardType, Code, Columns2, Copy, Eraser, Eye, File, FileBraces, FileImage, Folder, FolderOpen, Hash, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Highlighter, ImagePlus, Info, Italic, Link2Off, Link, List, ListOrdered, CornerDownLeft, Moon, PackagePlus, PanelLeft, Pilcrow, Quote, Save, Scissors, Search, SpellCheck, SquareCode, SquarePen, Strikethrough, Sun, TextSelect, ZoomIn } from 'lucide';
|
|
9
9
|
import { browserClipboardHasImage, readBrowserClipboardImage } from './browser.js';
|
|
@@ -13,7 +13,8 @@ import { MD_MARKDOWN_ICON } from './icons/md-markdown.js';
|
|
|
13
13
|
import { MDZIP_RUNTIME_LIBRARIES } from './library-info.js';
|
|
14
14
|
import { MdzipWorkspaceService, extensionForMime, normalizeArchivePath, relativeArchivePath } from './workspace.js';
|
|
15
15
|
import { buildMdzipNavTree, canEditMdzipPath, escapeHtml, isOrphanedMdzipAsset, mdzipEntryIconKind, isMdzipManifestPath, resolveMdzipArchiveLinkTarget, renderMdzipPreviewHtml } from './workspace-view.js';
|
|
16
|
-
import {
|
|
16
|
+
import { escapeMarkdownImageAlt, findImageReferenceAtOffset, formatImageEditMarkdown } from './image-edit.js';
|
|
17
|
+
import { MdzipRenderingService, defaultSafeMarkdownRenderer, groupTokensIntoChunks } from './rendering.js';
|
|
17
18
|
import { WORKSPACE_CSS } from './view-css.js';
|
|
18
19
|
const STYLE_ATTR = 'data-mdzip-ws-styles';
|
|
19
20
|
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff?)$/i;
|
|
@@ -31,6 +32,7 @@ const IMAGE_ICON_HTML = lucideIcon(FileImage, NAV_ICON_CLASS);
|
|
|
31
32
|
const FILE_ICON_HTML = lucideIcon(File, NAV_ICON_CLASS);
|
|
32
33
|
const ORPHAN_ICON_HTML = lucideIcon(Link2Off, '');
|
|
33
34
|
const SOURCE_EDIT_ICON_HTML = lucideIcon(SquarePen, TOOLBAR_ICON_CLASS);
|
|
35
|
+
const IMAGE_EDIT_AFFORDANCE_ICON_HTML = lucideIcon(SquarePen, 'mdzip-image-edit-affordance-icon');
|
|
34
36
|
const SOURCE_MARKDOWN_ICON_HTML = lucideIcon(Hash, TOOLBAR_ICON_CLASS);
|
|
35
37
|
const NAV_TOGGLE_ICON_HTML = lucideIcon(PanelLeft, `${TOOLBAR_ICON_CLASS} nav-toggle-icon`);
|
|
36
38
|
const CONVERT_TO_MDZ_ICON_HTML = lucideIcon(PackagePlus, `${TOOLBAR_ICON_CLASS} convert-mdz-icon`);
|
|
@@ -433,6 +435,29 @@ const mdzipEditorTheme = EditorView.theme({
|
|
|
433
435
|
color: 'var(--mdzip-muted-foreground-color)',
|
|
434
436
|
opacity: '0.65',
|
|
435
437
|
},
|
|
438
|
+
'.mdzip-image-edit-affordance': {
|
|
439
|
+
display: 'inline-flex',
|
|
440
|
+
alignItems: 'center',
|
|
441
|
+
justifyContent: 'center',
|
|
442
|
+
width: '20px',
|
|
443
|
+
height: '20px',
|
|
444
|
+
marginLeft: '2px',
|
|
445
|
+
padding: '0',
|
|
446
|
+
verticalAlign: 'middle',
|
|
447
|
+
border: 'none',
|
|
448
|
+
borderRadius: '4px',
|
|
449
|
+
background: 'var(--mdzip-widget-background-color)',
|
|
450
|
+
color: 'var(--mdzip-muted-foreground-color)',
|
|
451
|
+
cursor: 'pointer',
|
|
452
|
+
},
|
|
453
|
+
'.mdzip-image-edit-affordance:hover': {
|
|
454
|
+
background: 'var(--mdzip-control-hover-background-color)',
|
|
455
|
+
color: 'var(--mdzip-control-foreground-color)',
|
|
456
|
+
},
|
|
457
|
+
'.mdzip-image-edit-affordance-icon': {
|
|
458
|
+
width: '13px',
|
|
459
|
+
height: '13px',
|
|
460
|
+
},
|
|
436
461
|
'.cm-panels': {
|
|
437
462
|
background: 'var(--mdzip-widget-background-color)',
|
|
438
463
|
color: 'var(--mdzip-editor-foreground-color)',
|
|
@@ -555,6 +580,100 @@ const htmlTagMarkerHighlight = ViewPlugin.fromClass(class {
|
|
|
555
580
|
}, {
|
|
556
581
|
decorations: value => value.decorations
|
|
557
582
|
});
|
|
583
|
+
// Fenced/indented code blocks, inline code spans, and link/image URLs aren't
|
|
584
|
+
// prose — the browser's spellchecker has no dictionary for shell syntax or
|
|
585
|
+
// filenames, so it just underlines everything. Syntax-tree node ranges (not
|
|
586
|
+
// a MatchDecorator regexp) are required here because MatchDecorator only
|
|
587
|
+
// matches within a single line, and fenced code blocks span many.
|
|
588
|
+
const NO_SPELLCHECK_NODE_NAMES = new Set(['FencedCode', 'CodeBlock', 'InlineCode', 'URL']);
|
|
589
|
+
const noSpellcheckMark = Decoration.mark({ attributes: { spellcheck: 'false' } });
|
|
590
|
+
function buildNoSpellcheckDecorations(view) {
|
|
591
|
+
const builder = new RangeSetBuilder();
|
|
592
|
+
for (const { from, to } of view.visibleRanges) {
|
|
593
|
+
syntaxTree(view.state).iterate({
|
|
594
|
+
from,
|
|
595
|
+
to,
|
|
596
|
+
enter: (node) => {
|
|
597
|
+
if (NO_SPELLCHECK_NODE_NAMES.has(node.name)) {
|
|
598
|
+
builder.add(node.from, node.to, noSpellcheckMark);
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return builder.finish();
|
|
606
|
+
}
|
|
607
|
+
const noSpellcheckHighlight = ViewPlugin.fromClass(class {
|
|
608
|
+
constructor(view) {
|
|
609
|
+
this.decorations = buildNoSpellcheckDecorations(view);
|
|
610
|
+
}
|
|
611
|
+
update(update) {
|
|
612
|
+
if (update.docChanged || update.viewportChanged || syntaxTree(update.state) !== syntaxTree(update.startState)) {
|
|
613
|
+
this.decorations = buildNoSpellcheckDecorations(update.view);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}, {
|
|
617
|
+
decorations: value => value.decorations
|
|
618
|
+
});
|
|
619
|
+
// Click-to-edit affordance for existing image references. Populated
|
|
620
|
+
// on-click (not continuously, since the trigger is click not hover) via a
|
|
621
|
+
// StateEffect dispatched from the editor's `click` domEventHandler in
|
|
622
|
+
// createCmEditor. Fully inert unless a host imageEditHandler is set — see
|
|
623
|
+
// that click handler's own gate.
|
|
624
|
+
const IMAGE_EDIT_AFFORDANCE_CLICK_EVENT = 'mdzip-image-edit-affordance-click';
|
|
625
|
+
const imageEditAffordanceEffect = StateEffect.define();
|
|
626
|
+
class ImageEditAffordanceWidget extends WidgetType {
|
|
627
|
+
constructor(from, to) {
|
|
628
|
+
super();
|
|
629
|
+
this.from = from;
|
|
630
|
+
this.to = to;
|
|
631
|
+
}
|
|
632
|
+
eq(other) {
|
|
633
|
+
return other.from === this.from && other.to === this.to;
|
|
634
|
+
}
|
|
635
|
+
toDOM() {
|
|
636
|
+
const btn = document.createElement('button');
|
|
637
|
+
btn.type = 'button';
|
|
638
|
+
btn.className = 'mdzip-image-edit-affordance';
|
|
639
|
+
btn.setAttribute('aria-label', 'Edit image');
|
|
640
|
+
btn.setAttribute('data-mdzip-image-edit-affordance', '');
|
|
641
|
+
btn.innerHTML = IMAGE_EDIT_AFFORDANCE_ICON_HTML;
|
|
642
|
+
// Buttons are natively focusable; without this, clicking one moves DOM
|
|
643
|
+
// focus onto it and blurs the CodeMirror content — which would clear
|
|
644
|
+
// (and remove from the DOM) this very widget via the blur handler below
|
|
645
|
+
// before its own click event has a chance to fire.
|
|
646
|
+
btn.addEventListener('mousedown', (event) => event.preventDefault());
|
|
647
|
+
btn.addEventListener('click', (event) => {
|
|
648
|
+
event.preventDefault();
|
|
649
|
+
event.stopPropagation();
|
|
650
|
+
btn.dispatchEvent(new CustomEvent(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, {
|
|
651
|
+
bubbles: true,
|
|
652
|
+
detail: { from: this.from, to: this.to }
|
|
653
|
+
}));
|
|
654
|
+
});
|
|
655
|
+
return btn;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function imageEditAffordanceDeco(from, to) {
|
|
659
|
+
return Decoration.set([Decoration.widget({ widget: new ImageEditAffordanceWidget(from, to), side: 1 }).range(to)]);
|
|
660
|
+
}
|
|
661
|
+
const imageEditAffordanceField = StateField.define({
|
|
662
|
+
create: () => Decoration.none,
|
|
663
|
+
update(value, tr) {
|
|
664
|
+
value = value.map(tr.changes);
|
|
665
|
+
if (tr.docChanged) {
|
|
666
|
+
value = Decoration.none;
|
|
667
|
+
}
|
|
668
|
+
for (const effect of tr.effects) {
|
|
669
|
+
if (effect.is(imageEditAffordanceEffect)) {
|
|
670
|
+
value = effect.value ? imageEditAffordanceDeco(effect.value.from, effect.value.to) : Decoration.none;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return value;
|
|
674
|
+
},
|
|
675
|
+
provide: (field) => EditorView.decorations.from(field)
|
|
676
|
+
});
|
|
558
677
|
function injectStyles(doc) {
|
|
559
678
|
const existing = doc.querySelector(`style[${STYLE_ATTR}]`);
|
|
560
679
|
if (existing) {
|
|
@@ -738,6 +857,53 @@ export class MdzipWorkspaceView {
|
|
|
738
857
|
// trigger) there's no "currently open document" to race against and no
|
|
739
858
|
// de-dupe/staleness guard is needed.
|
|
740
859
|
this.packFilesDialogState = null;
|
|
860
|
+
// Tracks an in-progress progressive (chunked) preview render so Copy All
|
|
861
|
+
// can force-drain whatever's left unmounted. `cursor` is how many of
|
|
862
|
+
// `chunks` are mounted so far; null once everything's mounted (or when
|
|
863
|
+
// progressive rendering isn't active at all) — that's Copy All's signal
|
|
864
|
+
// to skip the dialog and copy instantly. `sentinelHandle` is the
|
|
865
|
+
// scroll-driven continuation's IntersectionObserver, if one is currently
|
|
866
|
+
// armed; Copy All tears it down before manually draining so the two don't
|
|
867
|
+
// race and double-mount the same chunk.
|
|
868
|
+
this.chunkedRenderState = null;
|
|
869
|
+
// Per-generation memo of renderChunk's raw HTML output, keyed by chunk
|
|
870
|
+
// index, shared between the DOM-mount path (renderAndMountChunkBatch) and
|
|
871
|
+
// Copy All with Images (renderFullDocumentHtml) so whichever renders a
|
|
872
|
+
// given chunk first is the only one that pays for it. A chunk's HTML is
|
|
873
|
+
// written at most once per generation, never recomputed-and-compared: the
|
|
874
|
+
// shipped mermaid extension mints each diagram's SVG id from a counter
|
|
875
|
+
// that lives on the extension instance for the view's whole lifetime, so
|
|
876
|
+
// re-rendering the same chunk twice yields different-but-valid output —
|
|
877
|
+
// only a genuine write-once cache is safe to share across call sites.
|
|
878
|
+
// Deliberately a separate field from chunkedRenderState (not nested in
|
|
879
|
+
// it): that becomes null as soon as every chunk is mounted, but this
|
|
880
|
+
// needs to stay alive for the whole generation, including after mounting
|
|
881
|
+
// finishes — an idle, fully-mounted document is exactly when Copy All
|
|
882
|
+
// should get full reuse.
|
|
883
|
+
this.chunkHtmlCache = null;
|
|
884
|
+
// Promise-based like packFilesDialogState, but there's no caller awaiting
|
|
885
|
+
// a decision here — `abort` is Copy All's own cancellation switch, wired
|
|
886
|
+
// to the dialog's Cancel button. `label` distinguishes Copy All's single
|
|
887
|
+
// "Rendering the full document" phase from Copy All with Images' two
|
|
888
|
+
// phases ("Rendering the document" then "Embedding images").
|
|
889
|
+
this.copyRenderDialogState = null;
|
|
890
|
+
// Third state sharing the same dialog element as copyRenderDialogState
|
|
891
|
+
// (all three are mutually exclusive) — set once rendering/embedding
|
|
892
|
+
// finishes for a copy that showed the progress dialog. The clipboard
|
|
893
|
+
// write itself is deliberately *not* fired automatically: the async
|
|
894
|
+
// Clipboard API requires a recent user gesture, and by the time a
|
|
895
|
+
// multi-second (sometimes multi-minute) prepare phase finishes, the
|
|
896
|
+
// gesture that triggered Copy All has expired — Chrome rejects the write
|
|
897
|
+
// with "blocked due to lack of user activation". Waiting for the user to
|
|
898
|
+
// click the dialog's own Copy button gives the write a fresh gesture to
|
|
899
|
+
// run inside. `perform` does the actual write when that happens.
|
|
900
|
+
this.copyReadyState = null;
|
|
901
|
+
// Set once a copy that showed the progress dialog finishes (a rejection
|
|
902
|
+
// included — see `copyReadyState` above for why that write happens from
|
|
903
|
+
// a button click, not automatically), so the completion message stays up
|
|
904
|
+
// until the user dismisses it, rather than a fleeting toast easy to miss
|
|
905
|
+
// after a long wait.
|
|
906
|
+
this.copyRenderDoneState = null;
|
|
741
907
|
this.navPaneWidth = 280;
|
|
742
908
|
this.splitRatio = 0.5;
|
|
743
909
|
this.resizing = false;
|
|
@@ -763,6 +929,12 @@ export class MdzipWorkspaceView {
|
|
|
763
929
|
this.tooltipState = null;
|
|
764
930
|
this.tooltipShowTimer = null;
|
|
765
931
|
this.tooltipHideTimer = null;
|
|
932
|
+
this.copyToastHideTimer = null;
|
|
933
|
+
// Not readonly: tests override these to keep the "clipboard write hangs
|
|
934
|
+
// forever" case fast rather than actually waiting out the real production
|
|
935
|
+
// timeouts.
|
|
936
|
+
this.clipboardWriteTimeoutMs = 30000;
|
|
937
|
+
this.clipboardFallbackWriteTimeoutMs = 15000;
|
|
766
938
|
this.cmEditor = null;
|
|
767
939
|
this.readOnlyCompartment = new Compartment();
|
|
768
940
|
this.lineNumbersCompartment = new Compartment();
|
|
@@ -774,6 +946,16 @@ export class MdzipWorkspaceView {
|
|
|
774
946
|
// syncScrollFromPreview/syncScrollToPreview).
|
|
775
947
|
this.lastSyncedEditorScrollTop = null;
|
|
776
948
|
this.lastSyncedPreviewScrollTop = null;
|
|
949
|
+
// Guards syncScrollToPreviewBottom against overlapping drains: set to the
|
|
950
|
+
// chunkedRenderState generation currently being force-drained, null when
|
|
951
|
+
// none is in flight. A second bottom-edge sync that arrives mid-drain
|
|
952
|
+
// (e.g. repeated wheel events once the editor is already pinned at max
|
|
953
|
+
// scrollTop) just no-ops — the in-flight call already owns finishing the
|
|
954
|
+
// job for that generation.
|
|
955
|
+
this.bottomDrainGeneration = null;
|
|
956
|
+
// Non-null while the scroll-to-bottom catch-up toast is showing (past its
|
|
957
|
+
// debounce) — see syncScrollToPreviewBottom.
|
|
958
|
+
this.scrollCatchUpState = null;
|
|
777
959
|
this.markdownExtensions = [];
|
|
778
960
|
this.entryRenderers = [];
|
|
779
961
|
this.renderingService = new MdzipRenderingService();
|
|
@@ -797,6 +979,7 @@ export class MdzipWorkspaceView {
|
|
|
797
979
|
this.controlPolicy = resolveMdzipControlPolicy(options.controls);
|
|
798
980
|
this.navigationMode = options.navigationMode ?? 'editor';
|
|
799
981
|
this.imageHydrationAnimation = options.imageHydrationAnimation ?? 'auto';
|
|
982
|
+
this.progressiveTextRendering = options.progressiveTextRendering ?? false;
|
|
800
983
|
this.toolbarDensity = options.toolbarDensity ?? 'comfortable';
|
|
801
984
|
this.contentDensity = options.contentDensity ?? 'comfortable';
|
|
802
985
|
this.previewMaxWidth = options.previewMaxWidth;
|
|
@@ -872,6 +1055,17 @@ export class MdzipWorkspaceView {
|
|
|
872
1055
|
this.elPackFilesModeProject = q('[data-ref="pack-files-mode-project"]');
|
|
873
1056
|
this.elPackFilesEntrySelect = q('[data-ref="pack-files-entry"]');
|
|
874
1057
|
this.elPackFilesConfirmBtn = q('[data-ref="pack-files-confirm-btn"]');
|
|
1058
|
+
this.elCopyRenderDialog = q('[data-ref="copy-render-dialog"]');
|
|
1059
|
+
this.elCopyRenderHeading = q('[data-ref="copy-render-heading"]');
|
|
1060
|
+
this.elCopyRenderProgressSection = q('[data-ref="copy-render-progress-section"]');
|
|
1061
|
+
this.elCopyRenderProgressBar = q('[data-ref="copy-render-progress-bar"]');
|
|
1062
|
+
this.elCopyRenderProgressText = q('[data-ref="copy-render-progress-text"]');
|
|
1063
|
+
this.elCopyRenderReadyText = q('[data-ref="copy-render-ready-text"]');
|
|
1064
|
+
this.elCopyRenderDoneText = q('[data-ref="copy-render-done-text"]');
|
|
1065
|
+
this.elCopyRenderCancelBtn = q('[data-ref="copy-render-cancel-btn"]');
|
|
1066
|
+
this.elCopyRenderReadyCancelBtn = q('[data-ref="copy-render-ready-cancel-btn"]');
|
|
1067
|
+
this.elCopyRenderConfirmBtn = q('[data-ref="copy-render-confirm-btn"]');
|
|
1068
|
+
this.elCopyRenderDismissBtn = q('[data-ref="copy-render-dismiss-btn"]');
|
|
875
1069
|
this.elNavMenu = q('[data-ref="nav-menu"]');
|
|
876
1070
|
this.elNameDialog = q('[data-ref="name-dialog"]');
|
|
877
1071
|
this.elNameDialogHeading = q('[data-ref="name-dialog-heading"]');
|
|
@@ -883,6 +1077,7 @@ export class MdzipWorkspaceView {
|
|
|
883
1077
|
this.elDeleteConfirmBtn = q('[data-ref="delete-confirm-btn"]');
|
|
884
1078
|
this.elReplaceInput = q('[data-ref="replace-input"]');
|
|
885
1079
|
this.elTooltip = q('[data-ref="tooltip"]');
|
|
1080
|
+
this.elCopyToast = q('[data-ref="copy-toast"]');
|
|
886
1081
|
this.elEmptyState = q('[data-ref="empty-state"]');
|
|
887
1082
|
this.prepareTooltips();
|
|
888
1083
|
this.attachEvents();
|
|
@@ -904,6 +1099,7 @@ export class MdzipWorkspaceView {
|
|
|
904
1099
|
this.resetRenderingState();
|
|
905
1100
|
this.cmEditor?.destroy();
|
|
906
1101
|
this.cmEditor = null;
|
|
1102
|
+
this.workspace?.dispose();
|
|
907
1103
|
this.workspace = null;
|
|
908
1104
|
this.replaceAssetSession(null);
|
|
909
1105
|
this.conversionDocumentGeneration += 1;
|
|
@@ -985,6 +1181,7 @@ export class MdzipWorkspaceView {
|
|
|
985
1181
|
this.resetRenderingState();
|
|
986
1182
|
this.cmEditor?.destroy();
|
|
987
1183
|
this.cmEditor = null;
|
|
1184
|
+
this.workspace?.dispose();
|
|
988
1185
|
this.workspace = null;
|
|
989
1186
|
this.replaceAssetSession(null);
|
|
990
1187
|
this.conversionDocumentGeneration += 1;
|
|
@@ -1178,6 +1375,12 @@ export class MdzipWorkspaceView {
|
|
|
1178
1375
|
this.teardownEntryRenderer();
|
|
1179
1376
|
// Release any whenRendered() waiters so their promises do not hang.
|
|
1180
1377
|
this.flushRenderedWaiters();
|
|
1378
|
+
try {
|
|
1379
|
+
this.workspace?.dispose();
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
// Ignore worker teardown errors
|
|
1383
|
+
}
|
|
1181
1384
|
try {
|
|
1182
1385
|
this.cmEditor?.destroy();
|
|
1183
1386
|
}
|
|
@@ -1290,6 +1493,9 @@ export class MdzipWorkspaceView {
|
|
|
1290
1493
|
this.options.imageInsertMode = options.imageInsertMode;
|
|
1291
1494
|
this.options.imageInsertHandler = options.imageInsertHandler;
|
|
1292
1495
|
}
|
|
1496
|
+
setImageEditOptions(options) {
|
|
1497
|
+
this.options.imageEditHandler = options.imageEditHandler;
|
|
1498
|
+
}
|
|
1293
1499
|
applyDensityClasses() {
|
|
1294
1500
|
this.elRoot.classList.remove('toolbar-density-comfortable', 'toolbar-density-compact', 'toolbar-density-dense', 'content-density-comfortable', 'content-density-compact');
|
|
1295
1501
|
this.elRoot.classList.add(`toolbar-density-${this.toolbarDensity}`);
|
|
@@ -1328,6 +1534,26 @@ export class MdzipWorkspaceView {
|
|
|
1328
1534
|
this.options.onFailed?.(error);
|
|
1329
1535
|
}
|
|
1330
1536
|
}
|
|
1537
|
+
// A new render generation invalidates any in-progress chunk draining —
|
|
1538
|
+
// Copy All's own abort check unwinds it and hides the dialog. It also
|
|
1539
|
+
// invalidates a "ready to copy" or "done" dialog left over from a
|
|
1540
|
+
// previous document: `copyReadyState.perform` closes over that
|
|
1541
|
+
// document's already-built HTML/text, and clicking Copy against a
|
|
1542
|
+
// now-superseded document would silently copy the wrong content.
|
|
1543
|
+
this.chunkedRenderState = null;
|
|
1544
|
+
this.chunkHtmlCache = null;
|
|
1545
|
+
this.copyRenderDialogState?.abort.abort();
|
|
1546
|
+
this.copyReadyState = null;
|
|
1547
|
+
this.copyRenderDoneState = null;
|
|
1548
|
+
}
|
|
1549
|
+
/** True when `this.previewMemo` — and by extension `this.previewGeneration` — still represents `snapshot`: nothing that feeds the preview (path, pathType, text, colorScheme) has changed since it was last rendered. */
|
|
1550
|
+
previewMemoMatchesSnapshot(snapshot) {
|
|
1551
|
+
const memo = this.previewMemo;
|
|
1552
|
+
return !!memo
|
|
1553
|
+
&& memo.path === snapshot.currentPath
|
|
1554
|
+
&& memo.pathType === snapshot.currentPathType
|
|
1555
|
+
&& memo.text === snapshot.currentText
|
|
1556
|
+
&& memo.colorScheme === this.colorScheme;
|
|
1331
1557
|
}
|
|
1332
1558
|
updatePreview(snapshot, entryClaimed) {
|
|
1333
1559
|
if (entryClaimed) {
|
|
@@ -1343,11 +1569,7 @@ export class MdzipWorkspaceView {
|
|
|
1343
1569
|
return;
|
|
1344
1570
|
}
|
|
1345
1571
|
const memo = this.previewMemo;
|
|
1346
|
-
if (
|
|
1347
|
-
&& memo.path === snapshot.currentPath
|
|
1348
|
-
&& memo.pathType === snapshot.currentPathType
|
|
1349
|
-
&& memo.text === snapshot.currentText
|
|
1350
|
-
&& memo.colorScheme === this.colorScheme) {
|
|
1572
|
+
if (this.previewMemoMatchesSnapshot(snapshot)) {
|
|
1351
1573
|
// Nothing that feeds the preview changed; keep the existing DOM and any
|
|
1352
1574
|
// mounted extension handles.
|
|
1353
1575
|
return;
|
|
@@ -1388,6 +1610,10 @@ export class MdzipWorkspaceView {
|
|
|
1388
1610
|
const abort = new AbortController();
|
|
1389
1611
|
this.previewAbort = abort;
|
|
1390
1612
|
const context = this.createMarkdownContext(snapshot, abort.signal);
|
|
1613
|
+
if (this.progressiveTextRendering && this.renderingService.supportsChunking) {
|
|
1614
|
+
this.renderChunkedPreview(snapshot, context, generation, animateImageHydration);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1391
1617
|
let result;
|
|
1392
1618
|
try {
|
|
1393
1619
|
result = this.renderingService.renderMarkdown(snapshot.currentText, context);
|
|
@@ -1459,28 +1685,86 @@ export class MdzipWorkspaceView {
|
|
|
1459
1685
|
*/
|
|
1460
1686
|
mountProgressivePreview(html, snapshot, context, generation, animateImageHydration) {
|
|
1461
1687
|
this.elPreviewContent.innerHTML = html;
|
|
1462
|
-
|
|
1463
|
-
|
|
1688
|
+
// Cheap pass over every <img> happens before extensions/code-block
|
|
1689
|
+
// controls mount, same relative order as always — see
|
|
1690
|
+
// collectPendingImages for why it's split from the (expensive) slot
|
|
1691
|
+
// creation that follows.
|
|
1692
|
+
const pending = this.collectPendingImages([this.elPreviewContent], animateImageHydration);
|
|
1693
|
+
this.mountPreviewExtensions(context, generation);
|
|
1694
|
+
const codeBlockHandle = this.mountCodeBlockControls();
|
|
1695
|
+
if (codeBlockHandle) {
|
|
1696
|
+
this.previewHandles.push(codeBlockHandle);
|
|
1697
|
+
}
|
|
1698
|
+
this.firePreviewRendered(snapshot, generation);
|
|
1699
|
+
this.hydrateImages(pending, context, generation, animateImageHydration, () => {
|
|
1700
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
/**
|
|
1704
|
+
* Cheap, attribute-only pass over every `<img>` under `roots`: no DOM tree
|
|
1705
|
+
* mutation. Archive-relative sources have their `src` stripped here
|
|
1706
|
+
* (immediately, so the browser never fires a bad network request for the
|
|
1707
|
+
* archive-relative path) and are returned for {@link hydrateImages};
|
|
1708
|
+
* external/data/fragment sources are left untouched (just get their align
|
|
1709
|
+
* class, if any — they never get a slot). Returns `[]` without touching
|
|
1710
|
+
* anything when there's no asset session to resolve archive images
|
|
1711
|
+
* against, matching `mountPreviewHtml`'s no-op image handling.
|
|
1712
|
+
*/
|
|
1713
|
+
collectPendingImages(roots, animateImageHydration) {
|
|
1714
|
+
if (!this.assetSession) {
|
|
1715
|
+
return [];
|
|
1716
|
+
}
|
|
1464
1717
|
const pending = [];
|
|
1465
|
-
for (const
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1718
|
+
for (const root of roots) {
|
|
1719
|
+
for (const image of Array.from(root.querySelectorAll('img'))) {
|
|
1720
|
+
applyRawHtmlImageSizeAttributes(image);
|
|
1721
|
+
const source = image.getAttribute('src');
|
|
1722
|
+
// Leave external, protocol-relative, data, and fragment URLs untouched.
|
|
1723
|
+
if (!source || /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i.test(source)) {
|
|
1724
|
+
const alignClass = rawHtmlImageAlignClass(image);
|
|
1725
|
+
if (alignClass) {
|
|
1726
|
+
image.classList.add(alignClass);
|
|
1727
|
+
}
|
|
1728
|
+
continue;
|
|
1473
1729
|
}
|
|
1474
|
-
|
|
1730
|
+
image.removeAttribute('src');
|
|
1731
|
+
if (animateImageHydration) {
|
|
1732
|
+
image.classList.add('mdzip-image-loading');
|
|
1733
|
+
}
|
|
1734
|
+
pending.push({ image, source });
|
|
1475
1735
|
}
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1736
|
+
}
|
|
1737
|
+
return pending;
|
|
1738
|
+
}
|
|
1739
|
+
/**
|
|
1740
|
+
* Slot-wraps and resolves `pending` images (from {@link collectPendingImages}),
|
|
1741
|
+
* only decompressing/resolving ones near the viewport up front — a
|
|
1742
|
+
* document with a few hundred distinct embedded images would otherwise
|
|
1743
|
+
* decompress and blob-URL every one of them synchronously, freezing the UI
|
|
1744
|
+
* for as long as that takes. Falls back to eager resolution when
|
|
1745
|
+
* IntersectionObserver isn't available (e.g. non-browser hosts). Calls
|
|
1746
|
+
* `onSettled` once every image in `pending` has resolved (or immediately
|
|
1747
|
+
* if `pending` is empty) — callers that want a single "hydrated" signal
|
|
1748
|
+
* for a whole preview pass `pending` from every root at once; callers
|
|
1749
|
+
* mounting one chunk among several within the same generation (see
|
|
1750
|
+
* {@link mountChunkBatch}) pass a no-op instead, so the signal only fires
|
|
1751
|
+
* once, for the batch it's semantically tied to.
|
|
1752
|
+
*/
|
|
1753
|
+
hydrateImages(pending, context, generation, animateImageHydration, onSettled) {
|
|
1754
|
+
const session = this.assetSession;
|
|
1755
|
+
if (!session || pending.length === 0) {
|
|
1756
|
+
onSettled();
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
const document = this.elPreviewContent.ownerDocument;
|
|
1760
|
+
let remaining = pending.length;
|
|
1761
|
+
const settle = () => {
|
|
1762
|
+
remaining -= 1;
|
|
1763
|
+
if (remaining === 0) {
|
|
1764
|
+
onSettled();
|
|
1483
1765
|
}
|
|
1766
|
+
};
|
|
1767
|
+
const createImageSlot = (image) => {
|
|
1484
1768
|
const slot = document.createElement('span');
|
|
1485
1769
|
slot.className = animateImageHydration
|
|
1486
1770
|
? 'mdzip-image-slot'
|
|
@@ -1494,26 +1778,9 @@ export class MdzipWorkspaceView {
|
|
|
1494
1778
|
}
|
|
1495
1779
|
image.parentNode?.insertBefore(slot, image);
|
|
1496
1780
|
slot.appendChild(image);
|
|
1497
|
-
|
|
1498
|
-
}
|
|
1499
|
-
this.mountPreviewExtensions(context, generation);
|
|
1500
|
-
const codeBlockHandle = this.mountCodeBlockControls();
|
|
1501
|
-
if (codeBlockHandle) {
|
|
1502
|
-
this.previewHandles.push(codeBlockHandle);
|
|
1503
|
-
}
|
|
1504
|
-
this.firePreviewRendered(snapshot, generation);
|
|
1505
|
-
if (!session || pending.length === 0) {
|
|
1506
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1507
|
-
return;
|
|
1508
|
-
}
|
|
1509
|
-
let remaining = pending.length;
|
|
1510
|
-
const settle = () => {
|
|
1511
|
-
remaining -= 1;
|
|
1512
|
-
if (remaining === 0) {
|
|
1513
|
-
this.fireAssetsHydrated(snapshot, generation);
|
|
1514
|
-
}
|
|
1781
|
+
return slot;
|
|
1515
1782
|
};
|
|
1516
|
-
|
|
1783
|
+
const hydrate = (image, slot, source) => {
|
|
1517
1784
|
void session.resolveImage(source, context.currentPath).then((resolved) => {
|
|
1518
1785
|
if (generation !== this.previewGeneration || context.signal.aborted) {
|
|
1519
1786
|
settle();
|
|
@@ -1545,6 +1812,294 @@ export class MdzipWorkspaceView {
|
|
|
1545
1812
|
this.openImageSlot(slot);
|
|
1546
1813
|
settle();
|
|
1547
1814
|
});
|
|
1815
|
+
};
|
|
1816
|
+
const observerWindow = document.defaultView;
|
|
1817
|
+
if (!observerWindow?.IntersectionObserver) {
|
|
1818
|
+
for (const { image, source } of pending) {
|
|
1819
|
+
hydrate(image, createImageSlot(image), source);
|
|
1820
|
+
}
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const bySlot = new Map();
|
|
1824
|
+
const observer = new observerWindow.IntersectionObserver((entries) => {
|
|
1825
|
+
for (const entry of entries) {
|
|
1826
|
+
if (!entry.isIntersecting)
|
|
1827
|
+
continue;
|
|
1828
|
+
observer.unobserve(entry.target);
|
|
1829
|
+
const item = bySlot.get(entry.target);
|
|
1830
|
+
if (item)
|
|
1831
|
+
hydrate(item.image, entry.target, item.source);
|
|
1832
|
+
}
|
|
1833
|
+
}, { root: this.elPreviewPane, rootMargin: '600px 0px' });
|
|
1834
|
+
this.previewHandles.push({ destroy: () => observer.disconnect() });
|
|
1835
|
+
// Slot creation + observer registration is a real DOM tree mutation per
|
|
1836
|
+
// image (as opposed to the cheap attribute-only pass in
|
|
1837
|
+
// collectPendingImages), and at thousands of occurrences (e.g. a chat
|
|
1838
|
+
// export where a handful of distinct avatars repeat across every row)
|
|
1839
|
+
// doing it all synchronously blocks the main thread for seconds.
|
|
1840
|
+
// Time-budget it across animation frames instead — the first chunk
|
|
1841
|
+
// still runs synchronously as part of this call (no yield before it),
|
|
1842
|
+
// so small documents (every existing test) see their slots exist
|
|
1843
|
+
// immediately, same as before.
|
|
1844
|
+
const CHUNK_BUDGET_MS = 8;
|
|
1845
|
+
let cursor = 0;
|
|
1846
|
+
const processChunk = () => {
|
|
1847
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
1848
|
+
return;
|
|
1849
|
+
const chunkStart = observerWindow.performance.now();
|
|
1850
|
+
while (cursor < pending.length && observerWindow.performance.now() - chunkStart < CHUNK_BUDGET_MS) {
|
|
1851
|
+
const item = pending[cursor];
|
|
1852
|
+
cursor += 1;
|
|
1853
|
+
const slot = createImageSlot(item.image);
|
|
1854
|
+
bySlot.set(slot, item);
|
|
1855
|
+
observer.observe(slot);
|
|
1856
|
+
}
|
|
1857
|
+
if (cursor < pending.length) {
|
|
1858
|
+
observerWindow.requestAnimationFrame(processChunk);
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1861
|
+
processChunk();
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* Opt-in (`progressiveTextRendering`) alternative to the whole-document
|
|
1865
|
+
* `renderMarkdown()` path above: tokenizes the markdown once, then renders
|
|
1866
|
+
* and mounts it in chunks near the viewport instead of all at once — see
|
|
1867
|
+
* {@link mountChunkedPreview}. Only reachable when
|
|
1868
|
+
* `renderingService.supportsChunking` is true (the default marked-based
|
|
1869
|
+
* renderer); the caller already checked that.
|
|
1870
|
+
*/
|
|
1871
|
+
renderChunkedPreview(snapshot, context, generation, animateImageHydration) {
|
|
1872
|
+
let tokens;
|
|
1873
|
+
try {
|
|
1874
|
+
tokens = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
|
|
1875
|
+
}
|
|
1876
|
+
catch (error) {
|
|
1877
|
+
this.options.onFailed?.(error);
|
|
1878
|
+
this.elPreviewContent.innerHTML = renderMdzipPreviewHtml(snapshot);
|
|
1879
|
+
this.firePreviewRendered(snapshot, generation);
|
|
1880
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
1881
|
+
return;
|
|
1882
|
+
}
|
|
1883
|
+
if (Array.isArray(tokens)) {
|
|
1884
|
+
this.mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
void tokens.then((resolved) => {
|
|
1888
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
1889
|
+
return;
|
|
1890
|
+
this.mountChunkedPreview(resolved, snapshot, context, generation, animateImageHydration);
|
|
1891
|
+
}).catch((error) => {
|
|
1892
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
1893
|
+
return;
|
|
1894
|
+
if (error?.name !== 'AbortError') {
|
|
1895
|
+
this.options.onFailed?.(error);
|
|
1896
|
+
}
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Groups tokens into chunks and mounts them: an initial batch synchronously
|
|
1901
|
+
* (enough for a small document to behave exactly like the non-chunked
|
|
1902
|
+
* path), then the rest as the user scrolls near a trailing sentinel
|
|
1903
|
+
* element, via the same `IntersectionObserver` + time-budgeted-rAF pattern
|
|
1904
|
+
* already used for image hydration — just for "need more text" instead of
|
|
1905
|
+
* "need this image". `onAssetsHydrated` fires once for the initial batch's
|
|
1906
|
+
* images only (not re-fired per later chunk) — `onPreviewRendered` still
|
|
1907
|
+
* means "the initial batch is in the DOM", same intent as the non-chunked
|
|
1908
|
+
* path, just proportional to the viewport instead of the whole document.
|
|
1909
|
+
*/
|
|
1910
|
+
mountChunkedPreview(tokens, snapshot, context, generation, animateImageHydration) {
|
|
1911
|
+
this.elPreviewContent.replaceChildren();
|
|
1912
|
+
// Also called with no options in renderFullDocumentHtml — see the
|
|
1913
|
+
// comment there; keep both call sites' chunking in sync.
|
|
1914
|
+
const chunks = groupTokensIntoChunks(tokens);
|
|
1915
|
+
if (chunks.length === 0) {
|
|
1916
|
+
this.chunkedRenderState = null;
|
|
1917
|
+
this.firePreviewRendered(snapshot, generation);
|
|
1918
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
this.chunkedRenderState = { chunks, cursor: 0, context, generation, animateImageHydration, sentinelHandle: null };
|
|
1922
|
+
void this.mountChunkBatch(chunks, 0, context, generation, animateImageHydration, () => {
|
|
1923
|
+
this.fireAssetsHydrated(snapshot, generation);
|
|
1924
|
+
}).then((cursor) => {
|
|
1925
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
1926
|
+
return;
|
|
1927
|
+
this.recordChunkProgress(generation, chunks, cursor);
|
|
1928
|
+
this.firePreviewRendered(snapshot, generation);
|
|
1929
|
+
if (cursor < chunks.length) {
|
|
1930
|
+
this.armChunkSentinel(chunks, cursor, context, generation, animateImageHydration);
|
|
1931
|
+
}
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Updates `chunkedRenderState.cursor` after a batch mounts, or clears the
|
|
1936
|
+
* whole state once every chunk is in the DOM — that `null` is Copy All's
|
|
1937
|
+
* signal that there's nothing left to force-render. A no-op if a newer
|
|
1938
|
+
* render generation has already superseded this one.
|
|
1939
|
+
*/
|
|
1940
|
+
recordChunkProgress(generation, chunks, cursor) {
|
|
1941
|
+
if (this.chunkedRenderState?.generation !== generation)
|
|
1942
|
+
return;
|
|
1943
|
+
if (cursor >= chunks.length) {
|
|
1944
|
+
this.chunkedRenderState = null;
|
|
1945
|
+
}
|
|
1946
|
+
else {
|
|
1947
|
+
this.chunkedRenderState.cursor = cursor;
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
/**
|
|
1951
|
+
* Renders+appends chunks starting at `startCursor` up to a char budget
|
|
1952
|
+
* (enough for one screenful, roughly) *or* a wall-clock time budget,
|
|
1953
|
+
* whichever comes first — char count alone is a poor proxy for cost on a
|
|
1954
|
+
* document where some chunks are plain text and others are dense with
|
|
1955
|
+
* `<img>` tags (parsing + sanitizing + inserting into an already-huge
|
|
1956
|
+
* `elPreviewContent` gets measurably more expensive per chunk as an
|
|
1957
|
+
* image-heavy document's accumulated DOM grows); the time budget catches
|
|
1958
|
+
* what the char budget alone misses. Mounts extensions/code-block controls
|
|
1959
|
+
* for exactly the chunks it appended (never re-scanning earlier ones), and
|
|
1960
|
+
* returns the cursor to resume from plus those chunks' roots (for the
|
|
1961
|
+
* caller to run `collectPendingImages` on). Shared by every caller that
|
|
1962
|
+
* mounts chunk batches — the initial batch, every later sentinel-triggered
|
|
1963
|
+
* continuation, and Copy All's drain — image hydration itself is *not*
|
|
1964
|
+
* included here; see {@link mountChunkBatch} and
|
|
1965
|
+
* {@link drainRemainingChunks} for the two different ways callers pace it.
|
|
1966
|
+
*/
|
|
1967
|
+
async renderAndMountChunkBatch(chunks, startCursor, context, generation) {
|
|
1968
|
+
const BATCH_CHAR_BUDGET = 4000;
|
|
1969
|
+
const BATCH_TIME_BUDGET_MS = 10;
|
|
1970
|
+
const clock = this.elPreviewContent.ownerDocument.defaultView?.performance ?? performance;
|
|
1971
|
+
const batchStart = clock.now();
|
|
1972
|
+
let cursor = startCursor;
|
|
1973
|
+
let renderedChars = 0;
|
|
1974
|
+
const mountedRoots = [];
|
|
1975
|
+
while (cursor < chunks.length
|
|
1976
|
+
&& renderedChars < BATCH_CHAR_BUDGET
|
|
1977
|
+
&& (mountedRoots.length === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
|
|
1978
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
1979
|
+
return { cursor, mountedRoots };
|
|
1980
|
+
const chunkTokens = chunks[cursor];
|
|
1981
|
+
const chunkIndex = cursor;
|
|
1982
|
+
cursor += 1;
|
|
1983
|
+
let html;
|
|
1984
|
+
let cacheHit;
|
|
1985
|
+
try {
|
|
1986
|
+
cacheHit = this.chunkHtmlCache?.generation === generation
|
|
1987
|
+
? this.chunkHtmlCache.html.get(chunkIndex)
|
|
1988
|
+
: undefined;
|
|
1989
|
+
if (cacheHit !== undefined) {
|
|
1990
|
+
html = cacheHit;
|
|
1991
|
+
}
|
|
1992
|
+
else {
|
|
1993
|
+
const result = this.renderingService.renderChunk(chunkTokens, context);
|
|
1994
|
+
html = typeof result === 'string' ? result : await result;
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
catch (error) {
|
|
1998
|
+
if (error?.name !== 'AbortError') {
|
|
1999
|
+
this.options.onFailed?.(error);
|
|
2000
|
+
}
|
|
2001
|
+
continue;
|
|
2002
|
+
}
|
|
2003
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2004
|
+
return { cursor, mountedRoots };
|
|
2005
|
+
if (cacheHit === undefined) {
|
|
2006
|
+
// groupTokensIntoChunks is also called with no options in
|
|
2007
|
+
// renderFullDocumentHtml — keep both call sites' chunking in sync,
|
|
2008
|
+
// since chunk index is this cache's only key.
|
|
2009
|
+
if (this.chunkHtmlCache?.generation !== generation) {
|
|
2010
|
+
this.chunkHtmlCache = { generation, html: new Map() };
|
|
2011
|
+
}
|
|
2012
|
+
this.chunkHtmlCache.html.set(chunkIndex, html);
|
|
2013
|
+
}
|
|
2014
|
+
const root = this.appendChunkHtml(html);
|
|
2015
|
+
mountedRoots.push(root);
|
|
2016
|
+
renderedChars += html.length;
|
|
2017
|
+
}
|
|
2018
|
+
for (const root of mountedRoots) {
|
|
2019
|
+
this.mountPreviewExtensions(context, generation, root);
|
|
2020
|
+
const codeBlockHandle = this.mountCodeBlockControls(root);
|
|
2021
|
+
if (codeBlockHandle) {
|
|
2022
|
+
this.previewHandles.push(codeBlockHandle);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
return { cursor, mountedRoots };
|
|
2026
|
+
}
|
|
2027
|
+
/**
|
|
2028
|
+
* `renderAndMountChunkBatch` plus its own immediate, independent
|
|
2029
|
+
* `hydrateImages` pass — the shape every caller except Copy All's drain
|
|
2030
|
+
* wants: mount a batch, then hydrate whatever images it contained,
|
|
2031
|
+
* decoupled from mounting the next batch. Scroll-paced callers (the
|
|
2032
|
+
* initial batch, every sentinel continuation) are naturally rate-limited
|
|
2033
|
+
* by how fast the user scrolls, so one independent hydration loop per
|
|
2034
|
+
* batch never has a chance to pile up against another.
|
|
2035
|
+
*/
|
|
2036
|
+
async mountChunkBatch(chunks, startCursor, context, generation, animateImageHydration, onImagesSettled) {
|
|
2037
|
+
const { cursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, startCursor, context, generation);
|
|
2038
|
+
// Same relative order as the non-chunked path: cheap image pass, then
|
|
2039
|
+
// the (expensive) slot/observe pass.
|
|
2040
|
+
const pending = this.collectPendingImages(mountedRoots, animateImageHydration);
|
|
2041
|
+
this.hydrateImages(pending, context, generation, animateImageHydration, onImagesSettled);
|
|
2042
|
+
return cursor;
|
|
2043
|
+
}
|
|
2044
|
+
/** Wraps one chunk's rendered HTML in a mount boundary and appends it. See the `.mdzip-chunk` CSS rules for why. */
|
|
2045
|
+
appendChunkHtml(html) {
|
|
2046
|
+
const doc = this.elPreviewContent.ownerDocument;
|
|
2047
|
+
const wrapper = doc.createElement('div');
|
|
2048
|
+
wrapper.className = 'mdzip-chunk';
|
|
2049
|
+
wrapper.innerHTML = html;
|
|
2050
|
+
this.elPreviewContent.appendChild(wrapper);
|
|
2051
|
+
return wrapper;
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* Appends a trailing sentinel and mounts the next chunk batch once it's
|
|
2055
|
+
* within `rootMargin` of the viewport — a larger margin than images'
|
|
2056
|
+
* (`hydrateImages`' 600px) since keeping text ahead of scroll is cheap
|
|
2057
|
+
* relative to keeping images ahead. Re-arms itself after each batch until
|
|
2058
|
+
* every chunk is mounted. Falls back to mounting everything immediately
|
|
2059
|
+
* when IntersectionObserver isn't available, matching `hydrateImages`.
|
|
2060
|
+
*/
|
|
2061
|
+
armChunkSentinel(chunks, cursor, context, generation, animateImageHydration) {
|
|
2062
|
+
const doc = this.elPreviewContent.ownerDocument;
|
|
2063
|
+
const observerWindow = doc.defaultView;
|
|
2064
|
+
const mountNext = (nextCursor) => {
|
|
2065
|
+
void this.mountChunkBatch(chunks, nextCursor, context, generation, animateImageHydration, () => { })
|
|
2066
|
+
.then((newCursor) => {
|
|
2067
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
2068
|
+
return;
|
|
2069
|
+
this.recordChunkProgress(generation, chunks, newCursor);
|
|
2070
|
+
if (newCursor < chunks.length) {
|
|
2071
|
+
this.armChunkSentinel(chunks, newCursor, context, generation, animateImageHydration);
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
};
|
|
2075
|
+
if (!observerWindow?.IntersectionObserver) {
|
|
2076
|
+
mountNext(cursor);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const sentinel = doc.createElement('div');
|
|
2080
|
+
sentinel.className = 'mdzip-chunk-sentinel';
|
|
2081
|
+
this.elPreviewContent.appendChild(sentinel);
|
|
2082
|
+
const observer = new observerWindow.IntersectionObserver((entries) => {
|
|
2083
|
+
for (const entry of entries) {
|
|
2084
|
+
if (!entry.isIntersecting)
|
|
2085
|
+
continue;
|
|
2086
|
+
observer.disconnect();
|
|
2087
|
+
sentinel.remove();
|
|
2088
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
2089
|
+
this.chunkedRenderState.sentinelHandle = null;
|
|
2090
|
+
}
|
|
2091
|
+
mountNext(cursor);
|
|
2092
|
+
}
|
|
2093
|
+
}, { root: this.elPreviewPane, rootMargin: '1600px 0px' });
|
|
2094
|
+
observer.observe(sentinel);
|
|
2095
|
+
// Stored on both previewHandles (torn down on the next render generation,
|
|
2096
|
+
// like every other preview handle) and chunkedRenderState.sentinelHandle
|
|
2097
|
+
// (torn down early by Copy All so its own manual drain doesn't race this
|
|
2098
|
+
// observer and double-mount a chunk).
|
|
2099
|
+
const handle = { destroy: () => { observer.disconnect(); sentinel.remove(); } };
|
|
2100
|
+
this.previewHandles.push(handle);
|
|
2101
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
2102
|
+
this.chunkedRenderState.sentinelHandle = handle;
|
|
1548
2103
|
}
|
|
1549
2104
|
}
|
|
1550
2105
|
/**
|
|
@@ -1607,13 +2162,20 @@ export class MdzipWorkspaceView {
|
|
|
1607
2162
|
+ 'CSP-restricted host (e.g. a VS Code webview), ensure img-src permits '
|
|
1608
2163
|
+ 'blob: and data:.'));
|
|
1609
2164
|
}
|
|
1610
|
-
|
|
2165
|
+
/**
|
|
2166
|
+
* `root` scopes extension `mount()` calls to a specific chunk instead of
|
|
2167
|
+
* the whole preview — used by chunked rendering (see
|
|
2168
|
+
* {@link mountChunkedPreview}), where extensions run once per newly
|
|
2169
|
+
* appended chunk rather than once over the whole document. Defaults to
|
|
2170
|
+
* the whole preview content, matching the non-chunked path exactly.
|
|
2171
|
+
*/
|
|
2172
|
+
mountPreviewExtensions(context, generation, root = this.elPreviewContent) {
|
|
1611
2173
|
for (const extension of this.markdownExtensions) {
|
|
1612
2174
|
if (!extension.mount) {
|
|
1613
2175
|
continue;
|
|
1614
2176
|
}
|
|
1615
2177
|
try {
|
|
1616
|
-
const mounted = extension.mount(
|
|
2178
|
+
const mounted = extension.mount(root, context);
|
|
1617
2179
|
if (isThenable(mounted)) {
|
|
1618
2180
|
void Promise.resolve(mounted).then((handle) => {
|
|
1619
2181
|
if (!handle) {
|
|
@@ -1653,12 +2215,15 @@ export class MdzipWorkspaceView {
|
|
|
1653
2215
|
* extension) — every consumer gets this automatically, gated by policy
|
|
1654
2216
|
* rather than opt-in wiring. Runs after `mountPreviewExtensions` so any
|
|
1655
2217
|
* extension-provided `pre > code` blocks already exist in the DOM. See #29.
|
|
2218
|
+
*
|
|
2219
|
+
* `root` scopes the scan to a specific chunk instead of the whole preview
|
|
2220
|
+
* — see {@link mountPreviewExtensions} for why.
|
|
1656
2221
|
*/
|
|
1657
|
-
mountCodeBlockControls() {
|
|
2222
|
+
mountCodeBlockControls(root = this.elPreviewContent) {
|
|
1658
2223
|
if (!this.controlPolicy.codeBlockTools) {
|
|
1659
2224
|
return null;
|
|
1660
2225
|
}
|
|
1661
|
-
const codeEls = Array.from(
|
|
2226
|
+
const codeEls = Array.from(root.querySelectorAll('pre > code'));
|
|
1662
2227
|
if (codeEls.length === 0) {
|
|
1663
2228
|
return null;
|
|
1664
2229
|
}
|
|
@@ -2001,6 +2566,8 @@ export class MdzipWorkspaceView {
|
|
|
2001
2566
|
syntaxHighlighting(mdzipMarkdownHighlight),
|
|
2002
2567
|
hardBreakMarkerHighlight,
|
|
2003
2568
|
htmlTagMarkerHighlight,
|
|
2569
|
+
noSpellcheckHighlight,
|
|
2570
|
+
imageEditAffordanceField,
|
|
2004
2571
|
EditorView.lineWrapping,
|
|
2005
2572
|
dropCursor(),
|
|
2006
2573
|
// Content is contenteditable, but browsers don't agree on a default
|
|
@@ -2056,6 +2623,23 @@ export class MdzipWorkspaceView {
|
|
|
2056
2623
|
void self.handleEditorImageDrop(file, event.clientX, event.clientY);
|
|
2057
2624
|
return true;
|
|
2058
2625
|
}
|
|
2626
|
+
},
|
|
2627
|
+
click(event, view) {
|
|
2628
|
+
// Fully inert unless a host has opted in — no parse cost, no
|
|
2629
|
+
// widget, matching #39's "default editor behavior unchanged".
|
|
2630
|
+
if (!self.options.imageEditHandler) {
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
const target = event.target;
|
|
2634
|
+
if (target.closest('[data-mdzip-image-edit-affordance]')) {
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
const pos = view.posAtCoords({ x: event.clientX, y: event.clientY });
|
|
2638
|
+
const hit = pos === null ? null : findImageReferenceAtOffset(view.state, pos);
|
|
2639
|
+
view.dispatch({ effects: imageEditAffordanceEffect.of(hit ? { from: hit.from, to: hit.to } : null) });
|
|
2640
|
+
},
|
|
2641
|
+
blur(_event, view) {
|
|
2642
|
+
view.dispatch({ effects: imageEditAffordanceEffect.of(null) });
|
|
2059
2643
|
}
|
|
2060
2644
|
}),
|
|
2061
2645
|
],
|
|
@@ -2066,6 +2650,10 @@ export class MdzipWorkspaceView {
|
|
|
2066
2650
|
if (scroller) {
|
|
2067
2651
|
scroller.addEventListener('scroll', () => self.syncScrollToPreview());
|
|
2068
2652
|
}
|
|
2653
|
+
editor.dom.addEventListener(IMAGE_EDIT_AFFORDANCE_CLICK_EVENT, (event) => {
|
|
2654
|
+
const { from, to } = event.detail;
|
|
2655
|
+
void self.openImageEditFlow(from, to);
|
|
2656
|
+
});
|
|
2069
2657
|
return editor;
|
|
2070
2658
|
}
|
|
2071
2659
|
async ensureCmEditor(force = false) {
|
|
@@ -2081,6 +2669,23 @@ export class MdzipWorkspaceView {
|
|
|
2081
2669
|
const snapshot = this.workspace?.snapshot() ?? null;
|
|
2082
2670
|
this.elEmptyState.hidden = snapshot !== null;
|
|
2083
2671
|
this.elWorkspaceShell.hidden = snapshot === null;
|
|
2672
|
+
// Must run before the no-snapshot early return below: the pack-files dialog is
|
|
2673
|
+
// deciding how to create a workspace, so it opens (via openPackFilesDialog ->
|
|
2674
|
+
// render()) precisely when no workspace exists yet. Bug found 2026-08-19 —
|
|
2675
|
+
// this used to live after the return, making it dead code for this dialog's
|
|
2676
|
+
// entire lifetime; the dialog's state was set correctly but never reached the
|
|
2677
|
+
// DOM, leaving the empty-state placeholder showing and the caller's promise
|
|
2678
|
+
// hanging forever.
|
|
2679
|
+
this.elPackFilesDialog.hidden = this.packFilesDialogState === null;
|
|
2680
|
+
if (this.packFilesDialogState) {
|
|
2681
|
+
const { request } = this.packFilesDialogState;
|
|
2682
|
+
this.elPackFilesEntrySelect.innerHTML = request.markdownFiles
|
|
2683
|
+
.map((p) => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`)
|
|
2684
|
+
.join('');
|
|
2685
|
+
this.elPackFilesEntrySelect.value = request.suggestedEntryPoint;
|
|
2686
|
+
this.elPackFilesModeDocument.checked = true;
|
|
2687
|
+
this.elPackFilesModeProject.checked = false;
|
|
2688
|
+
}
|
|
2084
2689
|
if (!snapshot) {
|
|
2085
2690
|
this.elDocumentStrip.hidden = true;
|
|
2086
2691
|
this.elToolbar.hidden = true;
|
|
@@ -2265,15 +2870,26 @@ export class MdzipWorkspaceView {
|
|
|
2265
2870
|
this.elImageInsertPositionSelect.value = 'inline';
|
|
2266
2871
|
this.updateImageInsertOptionControls();
|
|
2267
2872
|
}
|
|
2268
|
-
this.
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2873
|
+
this.elCopyRenderDialog.hidden = this.copyRenderDialogState === null
|
|
2874
|
+
&& this.copyReadyState === null
|
|
2875
|
+
&& this.copyRenderDoneState === null;
|
|
2876
|
+
this.elCopyRenderProgressSection.hidden = this.copyRenderDialogState === null;
|
|
2877
|
+
this.elCopyRenderReadyText.hidden = this.copyReadyState === null;
|
|
2878
|
+
this.elCopyRenderDoneText.hidden = this.copyRenderDoneState === null;
|
|
2879
|
+
this.elCopyRenderCancelBtn.hidden = this.copyRenderDialogState === null;
|
|
2880
|
+
this.elCopyRenderReadyCancelBtn.hidden = this.copyReadyState === null;
|
|
2881
|
+
this.elCopyRenderConfirmBtn.hidden = this.copyReadyState === null;
|
|
2882
|
+
this.elCopyRenderDismissBtn.hidden = this.copyRenderDoneState === null;
|
|
2883
|
+
if (this.copyRenderDialogState) {
|
|
2884
|
+
this.elCopyRenderHeading.textContent = 'Copying document...';
|
|
2885
|
+
this.updateCopyRenderDialogProgress();
|
|
2886
|
+
}
|
|
2887
|
+
else if (this.copyReadyState) {
|
|
2888
|
+
this.elCopyRenderHeading.textContent = 'Ready to copy';
|
|
2889
|
+
}
|
|
2890
|
+
else if (this.copyRenderDoneState) {
|
|
2891
|
+
this.elCopyRenderHeading.textContent = 'Done';
|
|
2892
|
+
this.elCopyRenderDoneText.textContent = this.copyRenderDoneState.message;
|
|
2277
2893
|
}
|
|
2278
2894
|
this.elMetadataDialog.hidden = !this.metadataDialogOpen;
|
|
2279
2895
|
if (this.contextMenuState) {
|
|
@@ -2367,10 +2983,11 @@ export class MdzipWorkspaceView {
|
|
|
2367
2983
|
}
|
|
2368
2984
|
});
|
|
2369
2985
|
// Ctrl/Cmd+A normally selects the whole page, which in split layout grabs
|
|
2370
|
-
// both panes' text at once. Scope it to
|
|
2371
|
-
// focus is inside the preview pane (see the
|
|
2372
|
-
// how it gets there); the source editor
|
|
2373
|
-
// binding since CodeMirror's content root
|
|
2986
|
+
// both panes' text at once. Scope it to a Copy All of the rendered
|
|
2987
|
+
// content instead when focus is inside the preview pane (see the
|
|
2988
|
+
// mousedown handler below for how it gets there); the source editor
|
|
2989
|
+
// keeps its own defaultKeymap binding since CodeMirror's content root
|
|
2990
|
+
// sits outside this element.
|
|
2374
2991
|
doc.addEventListener('keydown', (e) => {
|
|
2375
2992
|
if (e.key.toLowerCase() !== 'a' || !(e.ctrlKey || e.metaKey) || e.shiftKey || e.altKey) {
|
|
2376
2993
|
return;
|
|
@@ -2379,7 +2996,7 @@ export class MdzipWorkspaceView {
|
|
|
2379
2996
|
return;
|
|
2380
2997
|
}
|
|
2381
2998
|
e.preventDefault();
|
|
2382
|
-
this.
|
|
2999
|
+
void this.copyAllPreviewContent();
|
|
2383
3000
|
});
|
|
2384
3001
|
this.elNavBtn.addEventListener('click', () => {
|
|
2385
3002
|
if (!this.controlPolicy.navigation) {
|
|
@@ -2650,6 +3267,28 @@ export class MdzipWorkspaceView {
|
|
|
2650
3267
|
this.elPackFilesConfirmBtn.addEventListener('click', () => {
|
|
2651
3268
|
this.resolvePackFilesDialog(this.readPackFilesDialogDecision());
|
|
2652
3269
|
});
|
|
3270
|
+
this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-render"]')
|
|
3271
|
+
.addEventListener('click', () => {
|
|
3272
|
+
this.copyRenderDialogState?.abort.abort();
|
|
3273
|
+
});
|
|
3274
|
+
this.elCopyRenderDialog.querySelector('[data-action="cancel-copy-ready"]')
|
|
3275
|
+
.addEventListener('click', () => {
|
|
3276
|
+
this.copyReadyState = null;
|
|
3277
|
+
this.render();
|
|
3278
|
+
});
|
|
3279
|
+
this.elCopyRenderDialog.querySelector('[data-action="confirm-copy-ready"]')
|
|
3280
|
+
.addEventListener('click', () => {
|
|
3281
|
+
// Must call the actual write synchronously from this handler (no
|
|
3282
|
+
// awaits ahead of it) — this click is the fresh user gesture the
|
|
3283
|
+
// Clipboard API needs; losing it to another async hop before the
|
|
3284
|
+
// write starts would defeat the entire reason this button exists.
|
|
3285
|
+
void this.performReadyCopy();
|
|
3286
|
+
});
|
|
3287
|
+
this.elCopyRenderDialog.querySelector('[data-action="dismiss-copy-render"]')
|
|
3288
|
+
.addEventListener('click', () => {
|
|
3289
|
+
this.copyRenderDoneState = null;
|
|
3290
|
+
this.render();
|
|
3291
|
+
});
|
|
2653
3292
|
this.elNavMenu.addEventListener('click', (e) => {
|
|
2654
3293
|
e.stopPropagation();
|
|
2655
3294
|
const item = e.target.closest('[data-menu-action]');
|
|
@@ -2915,6 +3554,18 @@ export class MdzipWorkspaceView {
|
|
|
2915
3554
|
this.tooltipHideTimer = null;
|
|
2916
3555
|
}, 40);
|
|
2917
3556
|
}
|
|
3557
|
+
/** Briefly shows a status message (e.g. "Text copied to clipboard") near the bottom of the view, auto-hiding after a couple of seconds. */
|
|
3558
|
+
showCopyToast(message) {
|
|
3559
|
+
if (this.copyToastHideTimer) {
|
|
3560
|
+
clearTimeout(this.copyToastHideTimer);
|
|
3561
|
+
}
|
|
3562
|
+
this.elCopyToast.textContent = message;
|
|
3563
|
+
this.elCopyToast.hidden = false;
|
|
3564
|
+
this.copyToastHideTimer = setTimeout(() => {
|
|
3565
|
+
this.elCopyToast.hidden = true;
|
|
3566
|
+
this.copyToastHideTimer = null;
|
|
3567
|
+
}, 2000);
|
|
3568
|
+
}
|
|
2918
3569
|
async save() {
|
|
2919
3570
|
try {
|
|
2920
3571
|
const workspace = this.workspace;
|
|
@@ -2969,15 +3620,35 @@ export class MdzipWorkspaceView {
|
|
|
2969
3620
|
}
|
|
2970
3621
|
renderMetadata(snapshot) {
|
|
2971
3622
|
const manifest = snapshot.content.manifest;
|
|
3623
|
+
const isMdz = snapshot.sourceFormat === 'mdz';
|
|
2972
3624
|
const fields = [
|
|
2973
3625
|
['Filename', snapshot.fileName],
|
|
2974
|
-
['Format',
|
|
3626
|
+
['Format', isMdz ? 'MDZ package' : 'Markdown'],
|
|
3627
|
+
// For .mdz, archiveBytes really is what a save right now would write —
|
|
3628
|
+
// current in-memory bytes, edits included, not a stale re-read of disk.
|
|
3629
|
+
// For plain Markdown, archiveBytes is *not* that: it's some internally
|
|
3630
|
+
// wrapped representation with its own fixed overhead (verified: a
|
|
3631
|
+
// 5-byte markdown document reported 462 "archive" bytes) — use the
|
|
3632
|
+
// actual text's encoded size instead, which is what a .md save writes.
|
|
3633
|
+
['Size', formatByteSize(isMdz ? snapshot.archiveBytes.length : new TextEncoder().encode(snapshot.currentText).length)],
|
|
2975
3634
|
['Document title', snapshot.displayTitle],
|
|
2976
3635
|
['First heading', snapshot.headingFallback ?? 'Not found'],
|
|
2977
3636
|
['Created', formatMetadataValue(manifest?.created)],
|
|
2978
3637
|
['Modified', formatMetadataValue(manifest?.modified)],
|
|
2979
|
-
['Entry point',
|
|
3638
|
+
['Entry point', isMdz ? snapshot.content.entryPoint : 'Not applicable'],
|
|
3639
|
+
['Documents', isMdz ? String(snapshot.workspace.documents.length) : 'Not applicable'],
|
|
3640
|
+
['Assets', isMdz ? String(snapshot.workspace.assets.length) : 'Not applicable']
|
|
2980
3641
|
];
|
|
3642
|
+
// Only shown when actually read-only — most documents are editable, and a
|
|
3643
|
+
// "Read-only: No" row for the common case would just be noise. Spelled
|
|
3644
|
+
// out as a filesystem condition rather than an editor state: this mode is
|
|
3645
|
+
// driven entirely by the host (see MdzipWorkspaceOpenOptions.mode) — most
|
|
3646
|
+
// often because a host checked the file's OS/disk permissions, as the
|
|
3647
|
+
// vscode extension does — not something toggled inside the editor itself,
|
|
3648
|
+
// so the wording should point users at their file, not at this UI.
|
|
3649
|
+
if (snapshot.mode === 'read-only') {
|
|
3650
|
+
fields.splice(1, 0, ['Read-only', 'Yes — the file on disk (or its host) is not writable']);
|
|
3651
|
+
}
|
|
2981
3652
|
this.elMetadataList.replaceChildren(...fields.map(([label, value]) => {
|
|
2982
3653
|
return this.createMetadataRow(label, value);
|
|
2983
3654
|
}));
|
|
@@ -3293,6 +3964,61 @@ export class MdzipWorkspaceView {
|
|
|
3293
3964
|
this.cmEditor.focus();
|
|
3294
3965
|
}
|
|
3295
3966
|
}
|
|
3967
|
+
/**
|
|
3968
|
+
* Handles a click on an existing image's edit affordance (see
|
|
3969
|
+
* `imageEditAffordanceField`/the `click` domEventHandler in
|
|
3970
|
+
* createCmEditor). Only reachable when `imageEditHandler` is set — the
|
|
3971
|
+
* click handler that dispatches the affordance already gates on that.
|
|
3972
|
+
*/
|
|
3973
|
+
async openImageEditFlow(from, to) {
|
|
3974
|
+
const editor = this.cmEditor;
|
|
3975
|
+
const handler = this.options.imageEditHandler;
|
|
3976
|
+
if (!editor || !handler) {
|
|
3977
|
+
return;
|
|
3978
|
+
}
|
|
3979
|
+
const parsed = findImageReferenceAtOffset(editor.state, from);
|
|
3980
|
+
if (!parsed || parsed.from !== from || parsed.to !== to) {
|
|
3981
|
+
return;
|
|
3982
|
+
}
|
|
3983
|
+
const request = {
|
|
3984
|
+
src: parsed.src,
|
|
3985
|
+
altText: parsed.altText,
|
|
3986
|
+
width: parsed.width,
|
|
3987
|
+
height: parsed.height,
|
|
3988
|
+
position: parsed.position,
|
|
3989
|
+
mode: parsed.kind
|
|
3990
|
+
};
|
|
3991
|
+
let decision;
|
|
3992
|
+
try {
|
|
3993
|
+
decision = normalizeImageInsertDecision(await handler(request));
|
|
3994
|
+
}
|
|
3995
|
+
catch (error) {
|
|
3996
|
+
this.options.onFailed?.(error);
|
|
3997
|
+
decision = null;
|
|
3998
|
+
}
|
|
3999
|
+
this.cmEditor?.dispatch({ effects: imageEditAffordanceEffect.of(null) });
|
|
4000
|
+
if (!decision) {
|
|
4001
|
+
return; // cancelled
|
|
4002
|
+
}
|
|
4003
|
+
const current = this.cmEditor;
|
|
4004
|
+
if (!current) {
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
// The handler may have awaited arbitrarily long — revalidate against the
|
|
4008
|
+
// live doc instead of trusting the captured range/text, in case it
|
|
4009
|
+
// changed underneath while the dialog was open.
|
|
4010
|
+
const revalidated = findImageReferenceAtOffset(current.state, from);
|
|
4011
|
+
if (!revalidated || revalidated.raw !== parsed.raw) {
|
|
4012
|
+
this.options.onFailed?.(new Error('Image reference changed before the edit could be applied.'));
|
|
4013
|
+
return;
|
|
4014
|
+
}
|
|
4015
|
+
const replacement = formatImageEditMarkdown(revalidated.src, decision);
|
|
4016
|
+
current.dispatch({
|
|
4017
|
+
changes: { from: revalidated.from, to: revalidated.to, insert: replacement },
|
|
4018
|
+
selection: { anchor: revalidated.from + replacement.length }
|
|
4019
|
+
});
|
|
4020
|
+
current.focus();
|
|
4021
|
+
}
|
|
3296
4022
|
async resolveImageInsertDecision(bytes, mimeType, options) {
|
|
3297
4023
|
const size = sniffImageSize(bytes, mimeType);
|
|
3298
4024
|
const request = {
|
|
@@ -3589,7 +4315,7 @@ export class MdzipWorkspaceView {
|
|
|
3589
4315
|
}
|
|
3590
4316
|
}
|
|
3591
4317
|
// Items for the rendered-preview selection menu. Taking over `contextmenu`
|
|
3592
|
-
// to offer
|
|
4318
|
+
// to offer Copy All also suppresses the browser's native menu — which is
|
|
3593
4319
|
// the only thing that was otherwise offering Copy on a right-click — so
|
|
3594
4320
|
// Copy has to be reinstated explicitly whenever there's a selection.
|
|
3595
4321
|
previewMenuItems() {
|
|
@@ -3603,11 +4329,16 @@ export class MdzipWorkspaceView {
|
|
|
3603
4329
|
items.push(null);
|
|
3604
4330
|
}
|
|
3605
4331
|
items.push({
|
|
3606
|
-
action: 'preview-
|
|
3607
|
-
label: '
|
|
3608
|
-
icon:
|
|
4332
|
+
action: 'preview-copy-all',
|
|
4333
|
+
label: 'Copy All',
|
|
4334
|
+
icon: MENU_COPY_ICON_HTML,
|
|
3609
4335
|
shortcut: this.editorShortcut('A')
|
|
3610
4336
|
});
|
|
4337
|
+
items.push({
|
|
4338
|
+
action: 'preview-copy-all-images',
|
|
4339
|
+
label: 'Copy All with Images',
|
|
4340
|
+
icon: MENU_COPY_ICON_HTML
|
|
4341
|
+
});
|
|
3611
4342
|
return items;
|
|
3612
4343
|
}
|
|
3613
4344
|
handlePreviewMenuAction(action) {
|
|
@@ -3618,41 +4349,448 @@ export class MdzipWorkspaceView {
|
|
|
3618
4349
|
return;
|
|
3619
4350
|
}
|
|
3620
4351
|
switch (action) {
|
|
3621
|
-
case 'preview-
|
|
3622
|
-
this.
|
|
4352
|
+
case 'preview-copy-all':
|
|
4353
|
+
void this.copyAllPreviewContent();
|
|
4354
|
+
break;
|
|
4355
|
+
case 'preview-copy-all-images':
|
|
4356
|
+
void this.copyAllWithImagesPreviewContent();
|
|
3623
4357
|
break;
|
|
3624
4358
|
case 'preview-copy':
|
|
3625
|
-
|
|
4359
|
+
// Partial Copy never shows the render dialog (there's nothing to
|
|
4360
|
+
// drain), so its confirmation is always the brief toast.
|
|
4361
|
+
void this.copyPreviewSelection(state.text).then((outcome) => this.finishCopyNotification(false, outcome));
|
|
3626
4362
|
break;
|
|
3627
4363
|
}
|
|
3628
4364
|
}
|
|
4365
|
+
/**
|
|
4366
|
+
* Shows the copy confirmation either as a brief auto-hiding toast (an
|
|
4367
|
+
* instant copy — nothing for the user to have looked away from) or, if
|
|
4368
|
+
* the render dialog was showing, by switching that same dialog into a
|
|
4369
|
+
* dismissable "done" state instead of hiding it — a copy that took long
|
|
4370
|
+
* enough to need a progress dialog shouldn't end in a 2-second toast the
|
|
4371
|
+
* user may not be looking at. `outcome` null means there was nothing to
|
|
4372
|
+
* copy (empty selection) — silently clean up the dialog if one was
|
|
4373
|
+
* showing, no confirmation needed. An `error` outcome carries the actual
|
|
4374
|
+
* failure text inline (`onFailed` is a host callback with no guaranteed
|
|
4375
|
+
* visible surface — this component has no other way to guarantee the
|
|
4376
|
+
* user ever sees why it failed).
|
|
4377
|
+
*/
|
|
4378
|
+
finishCopyNotification(dialogWasShown, outcome) {
|
|
4379
|
+
if (!outcome) {
|
|
4380
|
+
if (dialogWasShown) {
|
|
4381
|
+
this.copyRenderDialogState = null;
|
|
4382
|
+
this.render();
|
|
4383
|
+
}
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
const text = 'error' in outcome ? `Copy failed: ${outcome.error}` : outcome.message;
|
|
4387
|
+
if (dialogWasShown) {
|
|
4388
|
+
this.copyRenderDialogState = null;
|
|
4389
|
+
this.copyRenderDoneState = { message: text };
|
|
4390
|
+
this.render();
|
|
4391
|
+
}
|
|
4392
|
+
else {
|
|
4393
|
+
this.showCopyToast(text);
|
|
4394
|
+
}
|
|
4395
|
+
}
|
|
4396
|
+
/** Switches the render dialog into its "ready to copy" state, holding `perform` until the user clicks Copy — see `copyReadyState` for why the write can't just happen automatically here. */
|
|
4397
|
+
armCopyReady(perform) {
|
|
4398
|
+
this.copyRenderDialogState = null;
|
|
4399
|
+
this.copyReadyState = { perform };
|
|
4400
|
+
this.render();
|
|
4401
|
+
}
|
|
4402
|
+
/** Runs the held write from `copyReadyState` — called directly from the dialog's Copy button click, which is what makes the write's own user-activation check pass. */
|
|
4403
|
+
async performReadyCopy() {
|
|
4404
|
+
const ready = this.copyReadyState;
|
|
4405
|
+
if (!ready)
|
|
4406
|
+
return;
|
|
4407
|
+
this.copyReadyState = null;
|
|
4408
|
+
const outcome = await ready.perform();
|
|
4409
|
+
// Always the dialog path: copyReadyState only ever gets armed for a
|
|
4410
|
+
// copy that already showed the progress dialog.
|
|
4411
|
+
this.finishCopyNotification(true, outcome);
|
|
4412
|
+
}
|
|
4413
|
+
/**
|
|
4414
|
+
* Races `promise` against a timer, rejecting with `timeoutMessage` if the
|
|
4415
|
+
* timer wins. The async Clipboard API has no built-in timeout, and a
|
|
4416
|
+
* write large enough to strain a real OS clipboard (a document with
|
|
4417
|
+
* thousands of embedded images can build a HTML payload well over
|
|
4418
|
+
* 100MB) can apparently hang indefinitely on some machines rather than
|
|
4419
|
+
* rejecting — without this, that leaves the operation permanently
|
|
4420
|
+
* pending, and the progress dialog never resolves into either a done or
|
|
4421
|
+
* a failure state.
|
|
4422
|
+
*/
|
|
4423
|
+
async withTimeout(promise, timeoutMs, timeoutMessage) {
|
|
4424
|
+
let timer;
|
|
4425
|
+
const timeout = new Promise((_, reject) => {
|
|
4426
|
+
timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
4427
|
+
});
|
|
4428
|
+
try {
|
|
4429
|
+
return await Promise.race([promise, timeout]);
|
|
4430
|
+
}
|
|
4431
|
+
finally {
|
|
4432
|
+
clearTimeout(timer);
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
/** Writes `text` to the clipboard. Returns null if there was nothing to copy (a true no-op, not a failure); otherwise a success message or the failure text (also reported via `onFailed`, but that's a host callback with no guaranteed visible surface — the caller needs the text itself to show the user). */
|
|
3629
4436
|
async copyPreviewSelection(text) {
|
|
3630
4437
|
if (!text) {
|
|
3631
|
-
return;
|
|
4438
|
+
return null;
|
|
3632
4439
|
}
|
|
3633
4440
|
try {
|
|
3634
|
-
await this.editorClipboard()?.writeText(text);
|
|
4441
|
+
await this.withTimeout(Promise.resolve(this.editorClipboard()?.writeText(text)), this.clipboardFallbackWriteTimeoutMs, 'Clipboard write timed out.');
|
|
4442
|
+
return { message: 'Plain text copied to clipboard' };
|
|
3635
4443
|
}
|
|
3636
4444
|
catch (error) {
|
|
3637
4445
|
this.options.onFailed?.(error);
|
|
4446
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
3638
4447
|
}
|
|
3639
4448
|
}
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
const
|
|
3646
|
-
|
|
4449
|
+
/** Syncs just the copy-render dialog's progress bar/text from `copyRenderDialogState` — see `copyAllPreviewContent` for why this bypasses `render()`. No-op while the dialog isn't showing. */
|
|
4450
|
+
updateCopyRenderDialogProgress() {
|
|
4451
|
+
if (!this.copyRenderDialogState) {
|
|
4452
|
+
return;
|
|
4453
|
+
}
|
|
4454
|
+
const { done, total, label } = this.copyRenderDialogState;
|
|
4455
|
+
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
4456
|
+
this.elCopyRenderProgressBar.style.width = `${percent}%`;
|
|
4457
|
+
this.elCopyRenderProgressText.textContent = total > 0 ? `${label} (${done} / ${total})...` : `${label}...`;
|
|
4458
|
+
}
|
|
4459
|
+
/**
|
|
4460
|
+
* Copies the entire rendered document as plain text (same fidelity as
|
|
4461
|
+
* `copyPreviewSelection` — no HTML, no `ClipboardItem`), regardless of how
|
|
4462
|
+
* much of it is currently mounted under progressive rendering. If
|
|
4463
|
+
* everything's already mounted (small doc, non-chunked render, or the user
|
|
4464
|
+
* already scrolled through it) this is instant — `chunkedRenderState` is
|
|
4465
|
+
* null in exactly that case. Otherwise it force-drains the rest first,
|
|
4466
|
+
* showing a cancelable progress dialog once the wait clears a short
|
|
4467
|
+
* debounce so fast documents never flicker it into view.
|
|
4468
|
+
*/
|
|
4469
|
+
async copyAllPreviewContent() {
|
|
4470
|
+
const pending = this.chunkedRenderState;
|
|
4471
|
+
if (!pending) {
|
|
4472
|
+
const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
|
|
4473
|
+
this.finishCopyNotification(false, outcome);
|
|
4474
|
+
return;
|
|
4475
|
+
}
|
|
4476
|
+
const total = pending.chunks.length;
|
|
4477
|
+
const abort = new AbortController();
|
|
4478
|
+
let dialogShown = false;
|
|
4479
|
+
const label = 'Rendering the full document';
|
|
4480
|
+
const showDialogTimer = setTimeout(() => {
|
|
4481
|
+
dialogShown = true;
|
|
4482
|
+
this.copyRenderDialogState = { done: pending.cursor, total, label, abort };
|
|
4483
|
+
this.render();
|
|
4484
|
+
}, 200);
|
|
4485
|
+
// Progress ticks update the dialog's progress bar/text directly instead
|
|
4486
|
+
// of going through the view's full `render()` — profiling a drain on a
|
|
4487
|
+
// 15,000-image document showed `render()` alone (it re-syncs the whole
|
|
4488
|
+
// shell, e.g. rebuilding the nav tree) costing ~870ms of self-time over
|
|
4489
|
+
// a 4s sample even throttled to 10/sec, dwarfing the actual chunk-mount
|
|
4490
|
+
// work it was meant to make room for.
|
|
4491
|
+
await this.drainRemainingChunks(pending, (done) => {
|
|
4492
|
+
if (!dialogShown)
|
|
4493
|
+
return;
|
|
4494
|
+
this.copyRenderDialogState = { done, total, label, abort };
|
|
4495
|
+
this.updateCopyRenderDialogProgress();
|
|
4496
|
+
}, abort.signal);
|
|
4497
|
+
clearTimeout(showDialogTimer);
|
|
4498
|
+
if (abort.signal.aborted) {
|
|
4499
|
+
if (dialogShown) {
|
|
4500
|
+
this.copyRenderDialogState = null;
|
|
4501
|
+
this.render();
|
|
4502
|
+
}
|
|
4503
|
+
// Cancelled partway through — whatever's left stays unmounted, so
|
|
4504
|
+
// re-arm the usual scroll-driven continuation for it.
|
|
4505
|
+
const remaining = this.chunkedRenderState;
|
|
4506
|
+
if (remaining && remaining.generation === pending.generation && !remaining.sentinelHandle) {
|
|
4507
|
+
this.armChunkSentinel(remaining.chunks, remaining.cursor, remaining.context, remaining.generation, remaining.animateImageHydration);
|
|
4508
|
+
}
|
|
4509
|
+
return;
|
|
4510
|
+
}
|
|
4511
|
+
if (dialogShown) {
|
|
4512
|
+
// The prepare phase above can take anywhere from seconds to minutes —
|
|
4513
|
+
// long enough that the click which started this has lost its user
|
|
4514
|
+
// activation by now, so the actual write waits for a fresh one
|
|
4515
|
+
// (the dialog's own Copy button) instead of firing automatically.
|
|
4516
|
+
// See copyReadyState's doc comment.
|
|
4517
|
+
this.armCopyReady(() => this.copyPreviewSelection(this.elPreviewContent.textContent ?? ''));
|
|
3647
4518
|
return;
|
|
3648
4519
|
}
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
4520
|
+
const outcome = await this.copyPreviewSelection(this.elPreviewContent.textContent ?? '');
|
|
4521
|
+
this.finishCopyNotification(false, outcome);
|
|
4522
|
+
}
|
|
4523
|
+
/**
|
|
4524
|
+
* Renders the *entire* current document to one HTML string, independent
|
|
4525
|
+
* of whatever's mounted in `elPreviewContent` — no DOM reads or writes.
|
|
4526
|
+
* Copy All with Images needs pristine `<img src="original/path">` markup
|
|
4527
|
+
* to hand to `MdzipAssetSession.rewriteHtmlEmbeddingImages`, and an
|
|
4528
|
+
* already-mounted, possibly-hydrated `<img>` in the live preview may have
|
|
4529
|
+
* had its `src` swapped for a `blob:` URL or stripped entirely pending lazy
|
|
4530
|
+
* load — either way the original archive path is gone. Time-budgeted and
|
|
4531
|
+
* yielded like `renderAndMountChunkBatch`, for the same reason: some
|
|
4532
|
+
* chunks cost far more to render+sanitize than others. Extension `mount()`
|
|
4533
|
+
* hooks are not run — they render into a live DOM (e.g. mermaid diagrams
|
|
4534
|
+
* turning marked-up code fences into SVG), which this never touches, so a
|
|
4535
|
+
* mermaid diagram will paste as its pre-render markup, not a rendered
|
|
4536
|
+
* diagram. Only reachable when `renderingService.supportsChunking` is
|
|
4537
|
+
* true; the caller checks that first.
|
|
4538
|
+
*/
|
|
4539
|
+
async renderFullDocumentHtml(snapshot, context, signal, cacheGeneration, onProgress) {
|
|
4540
|
+
const tokensResult = this.renderingService.tokenizeMarkdown(snapshot.currentText, context);
|
|
4541
|
+
const tokens = Array.isArray(tokensResult) ? tokensResult : await tokensResult;
|
|
4542
|
+
if (signal.aborted) {
|
|
4543
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
4544
|
+
}
|
|
4545
|
+
// groupTokensIntoChunks is also called with no options in
|
|
4546
|
+
// renderAndMountChunkBatch's caller (mountChunkedPreview) — keep both
|
|
4547
|
+
// call sites' chunking in sync, since chunk index is chunkHtmlCache's
|
|
4548
|
+
// only key.
|
|
4549
|
+
const chunks = groupTokensIntoChunks(tokens);
|
|
4550
|
+
const view = this.elPreviewContent.ownerDocument.defaultView;
|
|
4551
|
+
const clock = view?.performance ?? performance;
|
|
4552
|
+
const BATCH_TIME_BUDGET_MS = 10;
|
|
4553
|
+
let html = '';
|
|
4554
|
+
let cursor = 0;
|
|
4555
|
+
while (cursor < chunks.length) {
|
|
4556
|
+
if (signal.aborted) {
|
|
4557
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
4558
|
+
}
|
|
4559
|
+
const batchStart = clock.now();
|
|
4560
|
+
let processedInBatch = 0;
|
|
4561
|
+
while (cursor < chunks.length && (processedInBatch === 0 || clock.now() - batchStart < BATCH_TIME_BUDGET_MS)) {
|
|
4562
|
+
const chunkIndex = cursor;
|
|
4563
|
+
const cacheHit = cacheGeneration !== null && this.chunkHtmlCache?.generation === cacheGeneration
|
|
4564
|
+
? this.chunkHtmlCache.html.get(chunkIndex)
|
|
4565
|
+
: undefined;
|
|
4566
|
+
let chunkHtml;
|
|
4567
|
+
if (cacheHit !== undefined) {
|
|
4568
|
+
chunkHtml = cacheHit;
|
|
4569
|
+
}
|
|
4570
|
+
else {
|
|
4571
|
+
const result = this.renderingService.renderChunk(chunks[chunkIndex], context);
|
|
4572
|
+
chunkHtml = typeof result === 'string' ? result : await result;
|
|
4573
|
+
// Only populate the shared cache if this generation is *still*
|
|
4574
|
+
// the live one at the moment this (possibly async) render
|
|
4575
|
+
// finished — checked per-chunk, not once up front, since the
|
|
4576
|
+
// outer loop yields via requestAnimationFrame and individual
|
|
4577
|
+
// chunk renders (mermaid) can themselves be async. If the
|
|
4578
|
+
// document was edited mid-copy, cacheGeneration is now stale
|
|
4579
|
+
// even though this render (for the snapshot Copy All captured)
|
|
4580
|
+
// legitimately continues to completion — never write it back
|
|
4581
|
+
// under a generation number that's no longer current.
|
|
4582
|
+
if (cacheGeneration !== null && cacheGeneration === this.previewGeneration) {
|
|
4583
|
+
if (this.chunkHtmlCache?.generation !== cacheGeneration) {
|
|
4584
|
+
this.chunkHtmlCache = { generation: cacheGeneration, html: new Map() };
|
|
4585
|
+
}
|
|
4586
|
+
this.chunkHtmlCache.html.set(chunkIndex, chunkHtml);
|
|
4587
|
+
}
|
|
4588
|
+
}
|
|
4589
|
+
html += chunkHtml;
|
|
4590
|
+
cursor += 1;
|
|
4591
|
+
processedInBatch += 1;
|
|
4592
|
+
}
|
|
4593
|
+
onProgress?.(cursor, chunks.length);
|
|
4594
|
+
if (cursor >= chunks.length) {
|
|
4595
|
+
break;
|
|
4596
|
+
}
|
|
4597
|
+
if (signal.aborted) {
|
|
4598
|
+
throw new DOMException('Rendering aborted.', 'AbortError');
|
|
4599
|
+
}
|
|
4600
|
+
await new Promise((resolve) => {
|
|
4601
|
+
if (view?.requestAnimationFrame)
|
|
4602
|
+
view.requestAnimationFrame(() => resolve());
|
|
4603
|
+
else
|
|
4604
|
+
setTimeout(resolve, 0);
|
|
4605
|
+
});
|
|
4606
|
+
}
|
|
4607
|
+
return html;
|
|
4608
|
+
}
|
|
4609
|
+
/**
|
|
4610
|
+
* Like `copyAllPreviewContent`, but writes a rich `text/html` clipboard
|
|
4611
|
+
* representation (alongside the same `text/plain` fallback) with every
|
|
4612
|
+
* archive image re-embedded as a self-contained `data:` URL — the format
|
|
4613
|
+
* an external app like Word needs, since this document's own `blob:` URLs
|
|
4614
|
+
* only resolve inside this tab. Two phases share one debounced, cancelable
|
|
4615
|
+
* dialog: render the document fresh (`renderFullDocumentHtml`), then embed
|
|
4616
|
+
* its images (`rewriteHtmlEmbeddingImages`). Falls back to
|
|
4617
|
+
* `copyAllPreviewContent`'s plain-text-only behavior when there's no
|
|
4618
|
+
* default renderer to re-render from (a host-supplied custom renderer,
|
|
4619
|
+
* the same escape hatch `progressiveTextRendering` already has) — or, if
|
|
4620
|
+
* the clipboard rejects the rich write for any reason (unsupported
|
|
4621
|
+
* browser, payload too large), falls back to a plain-text write so the
|
|
4622
|
+
* user still gets *something* rather than nothing.
|
|
4623
|
+
*/
|
|
4624
|
+
async copyAllWithImagesPreviewContent() {
|
|
4625
|
+
const snapshot = this.workspace?.snapshot();
|
|
4626
|
+
if (!snapshot || snapshot.currentPathType !== 'markdown' || !this.renderingService.supportsChunking) {
|
|
4627
|
+
await this.copyAllPreviewContent();
|
|
4628
|
+
return;
|
|
4629
|
+
}
|
|
4630
|
+
const abort = new AbortController();
|
|
4631
|
+
let dialogShown = false;
|
|
4632
|
+
const showDialogTimer = setTimeout(() => {
|
|
4633
|
+
dialogShown = true;
|
|
4634
|
+
this.copyRenderDialogState = { done: 0, total: 0, label: 'Rendering the document', abort };
|
|
4635
|
+
this.render();
|
|
4636
|
+
}, 200);
|
|
4637
|
+
const updateProgress = (done, total, label) => {
|
|
4638
|
+
if (!dialogShown)
|
|
4639
|
+
return;
|
|
4640
|
+
this.copyRenderDialogState = { done, total, label, abort };
|
|
4641
|
+
this.updateCopyRenderDialogProgress();
|
|
4642
|
+
};
|
|
4643
|
+
let finalHtml;
|
|
4644
|
+
let imageCount = 0;
|
|
4645
|
+
try {
|
|
4646
|
+
const context = this.createMarkdownContext(snapshot, abort.signal);
|
|
4647
|
+
// Only trust chunkHtmlCache if the live preview generation still
|
|
4648
|
+
// represents this exact snapshot — if the preview hasn't caught up to
|
|
4649
|
+
// it yet, or the document has since changed, render fully fresh
|
|
4650
|
+
// rather than risk splicing in HTML for different content.
|
|
4651
|
+
const cacheGeneration = this.previewMemoMatchesSnapshot(snapshot) ? this.previewGeneration : null;
|
|
4652
|
+
let html = await this.renderFullDocumentHtml(snapshot, context, abort.signal, cacheGeneration, (done, total) => updateProgress(done, total, 'Rendering the document'));
|
|
4653
|
+
if (this.assetSession) {
|
|
4654
|
+
html = await this.assetSession.rewriteHtmlEmbeddingImages(html, snapshot.currentPath, abort.signal, (done, total) => {
|
|
4655
|
+
imageCount = total;
|
|
4656
|
+
updateProgress(done, total, 'Embedding images');
|
|
4657
|
+
});
|
|
4658
|
+
}
|
|
4659
|
+
finalHtml = html;
|
|
4660
|
+
}
|
|
4661
|
+
catch (error) {
|
|
4662
|
+
clearTimeout(showDialogTimer);
|
|
4663
|
+
if (error?.name === 'AbortError') {
|
|
4664
|
+
// Deliberate cancel — just close, no failure to report.
|
|
4665
|
+
if (dialogShown) {
|
|
4666
|
+
this.copyRenderDialogState = null;
|
|
4667
|
+
this.render();
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
else {
|
|
4671
|
+
this.finishCopyNotification(dialogShown, { error: error instanceof Error ? error.message : String(error) });
|
|
4672
|
+
this.options.onFailed?.(error);
|
|
4673
|
+
}
|
|
4674
|
+
return;
|
|
4675
|
+
}
|
|
4676
|
+
clearTimeout(showDialogTimer);
|
|
4677
|
+
const scratch = this.elPreviewContent.ownerDocument.createElement('div');
|
|
4678
|
+
scratch.innerHTML = finalHtml;
|
|
4679
|
+
const plainText = scratch.textContent ?? '';
|
|
4680
|
+
if (dialogShown) {
|
|
4681
|
+
// Same reasoning as copyAllPreviewContent: the prepare phase (often
|
|
4682
|
+
// the slower of the two here, once image embedding is involved) has
|
|
4683
|
+
// long since burned through the click's user activation.
|
|
4684
|
+
this.armCopyReady(() => this.writeRichClipboard(finalHtml, plainText, imageCount));
|
|
4685
|
+
return;
|
|
4686
|
+
}
|
|
4687
|
+
const outcome = await this.writeRichClipboard(finalHtml, plainText, imageCount);
|
|
4688
|
+
this.finishCopyNotification(false, outcome);
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* Writes an HTML+plain-text `ClipboardItem` when the browser supports it,
|
|
4692
|
+
* falling back to a plain-text-only `writeText` (same as
|
|
4693
|
+
* `copyPreviewSelection`, which reports itself as "Plain text") when it
|
|
4694
|
+
* doesn't, or if the rich write itself throws (e.g. a payload too large
|
|
4695
|
+
* for the OS clipboard) — either way the user ends up with *something* on
|
|
4696
|
+
* their clipboard rather than nothing. Returns the confirmation message
|
|
4697
|
+
* for the caller to show (see `finishCopyNotification`), naming the
|
|
4698
|
+
* actual MIME type that ended up on the clipboard ("HTML" for a
|
|
4699
|
+
* successful rich write, "Plain text" for the fallback) so the user knows
|
|
4700
|
+
* what they're about to paste — `imageCount` only adds to the "HTML"
|
|
4701
|
+
* wording, since even a rich write with zero images is still HTML, not
|
|
4702
|
+
* plain text.
|
|
4703
|
+
*/
|
|
4704
|
+
async writeRichClipboard(html, plainText, imageCount) {
|
|
4705
|
+
const clipboard = this.editorClipboard();
|
|
4706
|
+
const view = this.elPreviewContent.ownerDocument.defaultView;
|
|
4707
|
+
const ClipboardItemCtor = view?.ClipboardItem;
|
|
4708
|
+
if (clipboard && ClipboardItemCtor && 'write' in clipboard) {
|
|
4709
|
+
try {
|
|
4710
|
+
const item = new ClipboardItemCtor({
|
|
4711
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
4712
|
+
'text/plain': new Blob([plainText], { type: 'text/plain' })
|
|
4713
|
+
});
|
|
4714
|
+
// A document with thousands of repeated images (e.g. an avatar
|
|
4715
|
+
// reused across every row of a chat export) can build a payload
|
|
4716
|
+
// well over 100MB, since each occurrence embeds its own full copy
|
|
4717
|
+
// — the clipboard format has no way to reference a shared resource.
|
|
4718
|
+
// 30s is generous for a legitimate slow write; the real purpose is
|
|
4719
|
+
// making sure this can't hang forever with the dialog stuck mid-copy.
|
|
4720
|
+
await this.withTimeout(clipboard.write([item]), this.clipboardWriteTimeoutMs, 'Clipboard write timed out.');
|
|
4721
|
+
return {
|
|
4722
|
+
message: imageCount > 0
|
|
4723
|
+
? `HTML with ${imageCount} image${imageCount === 1 ? '' : 's'} copied to clipboard`
|
|
4724
|
+
: 'HTML copied to clipboard'
|
|
4725
|
+
};
|
|
4726
|
+
}
|
|
4727
|
+
catch (error) {
|
|
4728
|
+
this.options.onFailed?.(error);
|
|
4729
|
+
// Fall through to the plain-text fallback rather than surfacing
|
|
4730
|
+
// this error directly — if that also fails, its error is more
|
|
4731
|
+
// relevant (it's the one actually blocking the user from getting
|
|
4732
|
+
// anything at all), and the plain-text path already reports this
|
|
4733
|
+
// one to onFailed for anyone watching that.
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
return await this.copyPreviewSelection(plainText);
|
|
4737
|
+
}
|
|
4738
|
+
/**
|
|
4739
|
+
* Mounts every chunk from `state.cursor` onward, yielding to a fresh
|
|
4740
|
+
* animation frame between batches (unlike the sentinel path's rAF-time-
|
|
4741
|
+
* budgeted-per-batch loop, this has no viewport to wait on — it has to
|
|
4742
|
+
* plow through the whole rest of the document, so the explicit yield is
|
|
4743
|
+
* what keeps a huge draw from locking up the tab while it does). Stops
|
|
4744
|
+
* early if `signal` aborts, a newer render generation supersedes this one,
|
|
4745
|
+
* or the chunk state's own render context aborts.
|
|
4746
|
+
*
|
|
4747
|
+
* Deliberately uses `renderAndMountChunkBatch` instead of `mountChunkBatch`
|
|
4748
|
+
* and defers image hydration until the whole drain finishes, running it
|
|
4749
|
+
* once over every image the drain mounted, instead of once per batch:
|
|
4750
|
+
* Copy All only needs the mounted chunks' *text* (`elPreviewContent
|
|
4751
|
+
* .textContent`, which images never contribute to), so there's no reason
|
|
4752
|
+
* to wait on `hydrateImages`' async image resolution at all here — and
|
|
4753
|
+
* racing through batches as fast as this loop can while each one fires
|
|
4754
|
+
* its own independent, un-awaited `hydrateImages` background loop (as
|
|
4755
|
+
* `mountChunkBatch` does for the scroll-paced callers, where that's fine —
|
|
4756
|
+
* scrolling naturally rate-limits how many can ever be in flight at once)
|
|
4757
|
+
* lets dozens of those loops pile up concurrently on an image-heavy
|
|
4758
|
+
* document, each spending its own `CHUNK_BUDGET_MS` in the same frame —
|
|
4759
|
+
* measured 100-200ms frame gaps on a 15,000-image document. One combined
|
|
4760
|
+
* pass over every image at the end avoids the pile-up entirely, without
|
|
4761
|
+
* making Copy All wait on it.
|
|
4762
|
+
*/
|
|
4763
|
+
async drainRemainingChunks(state, onProgress, signal) {
|
|
4764
|
+
const { chunks, context, generation, animateImageHydration } = state;
|
|
4765
|
+
// Draining is instead of the scroll-driven continuation, not alongside
|
|
4766
|
+
// it — tearing down any armed sentinel first stops the two from racing
|
|
4767
|
+
// and double-mounting the same chunk.
|
|
4768
|
+
state.sentinelHandle?.destroy();
|
|
4769
|
+
if (this.chunkedRenderState?.generation === generation) {
|
|
4770
|
+
this.chunkedRenderState.sentinelHandle = null;
|
|
4771
|
+
}
|
|
4772
|
+
const allPending = [];
|
|
4773
|
+
try {
|
|
4774
|
+
while (this.chunkedRenderState?.generation === generation && this.chunkedRenderState.cursor < chunks.length) {
|
|
4775
|
+
if (signal.aborted || generation !== this.previewGeneration || context.signal.aborted)
|
|
4776
|
+
return;
|
|
4777
|
+
const cursor = this.chunkedRenderState.cursor;
|
|
4778
|
+
const { cursor: newCursor, mountedRoots } = await this.renderAndMountChunkBatch(chunks, cursor, context, generation);
|
|
4779
|
+
if (generation !== this.previewGeneration || context.signal.aborted)
|
|
4780
|
+
return;
|
|
4781
|
+
allPending.push(...this.collectPendingImages(mountedRoots, animateImageHydration));
|
|
4782
|
+
this.recordChunkProgress(generation, chunks, newCursor);
|
|
4783
|
+
onProgress(newCursor, chunks.length);
|
|
4784
|
+
if (signal.aborted)
|
|
4785
|
+
return;
|
|
4786
|
+
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
finally {
|
|
4790
|
+
if (generation === this.previewGeneration && !context.signal.aborted) {
|
|
4791
|
+
this.hydrateImages(allPending, context, generation, animateImageHydration, () => { });
|
|
4792
|
+
}
|
|
4793
|
+
}
|
|
3656
4794
|
}
|
|
3657
4795
|
// Inserts a fenced code block, carrying the chosen language as the fence info
|
|
3658
4796
|
// string. An empty language produces a plain ```` ``` ```` block.
|
|
@@ -4658,8 +5796,29 @@ export class MdzipWorkspaceView {
|
|
|
4658
5796
|
if (this.lastSyncedEditorScrollTop !== null && Math.abs(currentTop - this.lastSyncedEditorScrollTop) < 2) {
|
|
4659
5797
|
return;
|
|
4660
5798
|
}
|
|
4661
|
-
this.syncing = true;
|
|
4662
5799
|
const editorHeight = cmScroller.scrollHeight - cmScroller.clientHeight;
|
|
5800
|
+
// The editor is at its true bottom, but under progressive rendering the
|
|
5801
|
+
// preview's scrollHeight only accounts for chunks mounted so far — a
|
|
5802
|
+
// plain ratio jump would only reach the bottom of whatever happens to be
|
|
5803
|
+
// mounted right now, not the document's actual end. Force-drain the rest
|
|
5804
|
+
// first so this edge is reliable regardless of how much has been
|
|
5805
|
+
// scrolled-into-view on the preview side.
|
|
5806
|
+
//
|
|
5807
|
+
// "At the bottom" is detected via CodeMirror's own viewport, not a
|
|
5808
|
+
// scrollHeight/scrollTop pixel comparison: CodeMirror estimates the
|
|
5809
|
+
// height of not-yet-measured (virtualized) lines, and on a huge document
|
|
5810
|
+
// that estimate can be tens of pixels off from where it actually clamps
|
|
5811
|
+
// scrollTop — confirmed live against a real 88,000-line file, where a
|
|
5812
|
+
// small fixed pixel epsilon never matched. `viewport.to` is what
|
|
5813
|
+
// CodeMirror has actually decided to draw for the current scroll
|
|
5814
|
+
// position, so comparing it to the document length is exact regardless
|
|
5815
|
+
// of any height estimation drift.
|
|
5816
|
+
const atDocEnd = this.cmEditor.viewport.to >= this.cmEditor.state.doc.length;
|
|
5817
|
+
if (atDocEnd && this.chunkedRenderState) {
|
|
5818
|
+
void this.syncScrollToPreviewBottom();
|
|
5819
|
+
return;
|
|
5820
|
+
}
|
|
5821
|
+
this.syncing = true;
|
|
4663
5822
|
const scrollRatio = editorHeight > 0 ? currentTop / editorHeight : 0;
|
|
4664
5823
|
const previewHeight = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
|
|
4665
5824
|
const target = scrollRatio * previewHeight;
|
|
@@ -4667,6 +5826,84 @@ export class MdzipWorkspaceView {
|
|
|
4667
5826
|
this.elPreviewPane.scrollTop = target;
|
|
4668
5827
|
this.syncing = false;
|
|
4669
5828
|
}
|
|
5829
|
+
/**
|
|
5830
|
+
* Handles syncScrollToPreview's bottom edge when the preview still has
|
|
5831
|
+
* unmounted chunks: force-mounts the rest (same drain Copy All uses) so
|
|
5832
|
+
* the preview's scrollHeight reflects the whole document, then jumps to
|
|
5833
|
+
* its real bottom — instead of the ordinary ratio-based jump, which would
|
|
5834
|
+
* only land at the bottom of whatever was mounted the instant the sync
|
|
5835
|
+
* fired. Re-checks that the editor is still at its bottom and the document
|
|
5836
|
+
* hasn't changed before applying the jump, since the drain can take long
|
|
5837
|
+
* enough on a huge document for either to no longer hold.
|
|
5838
|
+
*
|
|
5839
|
+
* On the most extreme real documents this drain has been measured at
|
|
5840
|
+
* ~106s (thousands of chunks, tens of thousands of images each needing a
|
|
5841
|
+
* real DOM slot + IntersectionObserver registration) — long enough that a
|
|
5842
|
+
* silent wait looks indistinguishable from a frozen preview pane. Past a
|
|
5843
|
+
* short debounce (so ordinary documents never see it), a small status
|
|
5844
|
+
* toast shows progress, reusing the same element Copy All's confirmation
|
|
5845
|
+
* messages use.
|
|
5846
|
+
*/
|
|
5847
|
+
async syncScrollToPreviewBottom() {
|
|
5848
|
+
const state = this.chunkedRenderState;
|
|
5849
|
+
if (!state || this.bottomDrainGeneration === state.generation) {
|
|
5850
|
+
return;
|
|
5851
|
+
}
|
|
5852
|
+
const generation = state.generation;
|
|
5853
|
+
this.bottomDrainGeneration = generation;
|
|
5854
|
+
if (this.copyToastHideTimer) {
|
|
5855
|
+
clearTimeout(this.copyToastHideTimer);
|
|
5856
|
+
this.copyToastHideTimer = null;
|
|
5857
|
+
}
|
|
5858
|
+
let showToastTimer = setTimeout(() => {
|
|
5859
|
+
showToastTimer = null;
|
|
5860
|
+
this.scrollCatchUpState = { done: state.cursor, total: state.chunks.length };
|
|
5861
|
+
this.updateScrollCatchUpToast();
|
|
5862
|
+
}, 200);
|
|
5863
|
+
try {
|
|
5864
|
+
await this.drainRemainingChunks(state, (done, total) => {
|
|
5865
|
+
if (!this.scrollCatchUpState)
|
|
5866
|
+
return;
|
|
5867
|
+
this.scrollCatchUpState = { done, total };
|
|
5868
|
+
this.updateScrollCatchUpToast();
|
|
5869
|
+
}, new AbortController().signal);
|
|
5870
|
+
}
|
|
5871
|
+
finally {
|
|
5872
|
+
if (showToastTimer) {
|
|
5873
|
+
clearTimeout(showToastTimer);
|
|
5874
|
+
}
|
|
5875
|
+
if (this.scrollCatchUpState) {
|
|
5876
|
+
this.scrollCatchUpState = null;
|
|
5877
|
+
this.elCopyToast.hidden = true;
|
|
5878
|
+
}
|
|
5879
|
+
if (this.bottomDrainGeneration === generation) {
|
|
5880
|
+
this.bottomDrainGeneration = null;
|
|
5881
|
+
}
|
|
5882
|
+
}
|
|
5883
|
+
if (generation !== this.previewGeneration || this.layout !== 'split' || !this.cmEditor) {
|
|
5884
|
+
return;
|
|
5885
|
+
}
|
|
5886
|
+
// See the comment in syncScrollToPreview on why this is viewport-based
|
|
5887
|
+
// rather than a scrollHeight/scrollTop pixel comparison.
|
|
5888
|
+
if (this.cmEditor.viewport.to < this.cmEditor.state.doc.length) {
|
|
5889
|
+
return;
|
|
5890
|
+
}
|
|
5891
|
+
this.syncing = true;
|
|
5892
|
+
const target = this.elPreviewPane.scrollHeight - this.elPreviewPane.clientHeight;
|
|
5893
|
+
this.lastSyncedPreviewScrollTop = target;
|
|
5894
|
+
this.elPreviewPane.scrollTop = target;
|
|
5895
|
+
this.syncing = false;
|
|
5896
|
+
}
|
|
5897
|
+
/** Syncs the catch-up toast's text from `scrollCatchUpState`, bypassing `render()` — see `updateCopyRenderDialogProgress` for why. No-op while the toast isn't showing. */
|
|
5898
|
+
updateScrollCatchUpToast() {
|
|
5899
|
+
if (!this.scrollCatchUpState) {
|
|
5900
|
+
return;
|
|
5901
|
+
}
|
|
5902
|
+
const { done, total } = this.scrollCatchUpState;
|
|
5903
|
+
const percent = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
5904
|
+
this.elCopyToast.textContent = total > 0 ? `Catching up the preview... (${percent}%)` : 'Catching up the preview...';
|
|
5905
|
+
this.elCopyToast.hidden = false;
|
|
5906
|
+
}
|
|
4670
5907
|
}
|
|
4671
5908
|
function canShowSourceLayout(snapshot) {
|
|
4672
5909
|
return canEditMdzipPath(snapshot.currentPathType, snapshot.currentPath, 'editable');
|
|
@@ -4791,8 +6028,24 @@ function padHtmlImageBlock(html, currentText, selectionStart, selectionEnd) {
|
|
|
4791
6028
|
: after.startsWith('\n') ? '\n' : '\n\n';
|
|
4792
6029
|
return `${prefix}${html}${suffix}`;
|
|
4793
6030
|
}
|
|
4794
|
-
function
|
|
4795
|
-
|
|
6031
|
+
function formatByteSize(bytes) {
|
|
6032
|
+
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
6033
|
+
return 'Not available';
|
|
6034
|
+
}
|
|
6035
|
+
if (bytes < 1024) {
|
|
6036
|
+
return `${bytes} B`;
|
|
6037
|
+
}
|
|
6038
|
+
const units = ['KB', 'MB', 'GB'];
|
|
6039
|
+
let value = bytes / 1024;
|
|
6040
|
+
let unitIndex = 0;
|
|
6041
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
6042
|
+
value /= 1024;
|
|
6043
|
+
unitIndex += 1;
|
|
6044
|
+
}
|
|
6045
|
+
// One decimal below 10 of a unit (e.g. "1.4 MB"), none above (e.g. "23 MB") —
|
|
6046
|
+
// matches how OS file managers commonly round these.
|
|
6047
|
+
const formatted = value < 10 ? value.toFixed(1) : Math.round(value).toString();
|
|
6048
|
+
return `${formatted} ${units[unitIndex]}`;
|
|
4796
6049
|
}
|
|
4797
6050
|
function formatMetadataValue(value) {
|
|
4798
6051
|
if (typeof value === 'string' && value.trim()) {
|
|
@@ -5052,6 +6305,30 @@ const SHELL_HTML = `
|
|
|
5052
6305
|
</div>
|
|
5053
6306
|
</div>
|
|
5054
6307
|
|
|
6308
|
+
<div class="title-dialog-backdrop" data-ref="copy-render-dialog" hidden
|
|
6309
|
+
role="dialog" aria-modal="true" aria-labelledby="mdzip-copy-render-dialog-heading">
|
|
6310
|
+
<div class="title-dialog copy-render-dialog">
|
|
6311
|
+
<h3 id="mdzip-copy-render-dialog-heading" data-ref="copy-render-heading">Copying document...</h3>
|
|
6312
|
+
<div data-ref="copy-render-progress-section">
|
|
6313
|
+
<p data-ref="copy-render-progress-text">Rendering the full document...</p>
|
|
6314
|
+
<div class="copy-render-progress-track">
|
|
6315
|
+
<div class="copy-render-progress-bar" data-ref="copy-render-progress-bar"></div>
|
|
6316
|
+
</div>
|
|
6317
|
+
</div>
|
|
6318
|
+
<p data-ref="copy-render-ready-text" hidden>
|
|
6319
|
+
The document is ready. Browsers only allow a clipboard write right after a click, so this needs one more —
|
|
6320
|
+
click Copy to finish.
|
|
6321
|
+
</p>
|
|
6322
|
+
<p data-ref="copy-render-done-text" hidden></p>
|
|
6323
|
+
<div class="title-dialog-actions">
|
|
6324
|
+
<button type="button" data-action="cancel-copy-render" data-ref="copy-render-cancel-btn">Cancel</button>
|
|
6325
|
+
<button type="button" data-action="cancel-copy-ready" data-ref="copy-render-ready-cancel-btn" hidden>Cancel</button>
|
|
6326
|
+
<button type="button" class="save-title" data-action="confirm-copy-ready" data-ref="copy-render-confirm-btn" hidden>Copy</button>
|
|
6327
|
+
<button type="button" class="save-title" data-action="dismiss-copy-render" data-ref="copy-render-dismiss-btn" hidden>Dismiss</button>
|
|
6328
|
+
</div>
|
|
6329
|
+
</div>
|
|
6330
|
+
</div>
|
|
6331
|
+
|
|
5055
6332
|
<div class="title-dialog-backdrop" data-ref="metadata-dialog" hidden
|
|
5056
6333
|
role="dialog" aria-modal="true" aria-labelledby="mdzip-metadata-dialog-heading">
|
|
5057
6334
|
<div class="title-dialog metadata-dialog">
|
|
@@ -5096,6 +6373,8 @@ const SHELL_HTML = `
|
|
|
5096
6373
|
|
|
5097
6374
|
<div class="mdzip-tooltip" data-ref="tooltip" role="tooltip" hidden></div>
|
|
5098
6375
|
|
|
6376
|
+
<div class="mdzip-copy-toast" data-ref="copy-toast" role="status" aria-live="polite" hidden></div>
|
|
6377
|
+
|
|
5099
6378
|
<p class="mdzip-empty" data-ref="empty-state">No MDZip workspace loaded.</p>
|
|
5100
6379
|
</section>
|
|
5101
6380
|
`;
|